diff --git a/CHANGELOG.md b/CHANGELOG.md
index 85707536ec..d1a870a6c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Releases marked with 🧪 (or previously with the "beta" suffix) were released o
* 🔧 Shows: move manage lists button from more options menu to inside the overview and details tabs.
* 🔨 Trakt: downloading ratings also works if API returns rating 0.
+* 🔨 Cloud: after deleting an account, signing in no longer re-uses the deleted account.
## Version 2026.2
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..4af8773f7d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -2,20 +2,30 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.FileInputStream
import java.util.Properties
+// For CI (and so debug builds just work), use a placeholder file for google-services plugin
+val googleServicesJsonFile = project.file("google-services.json")
+val placeholderWarningMessage =
+ "WARNING: Using google-services.json with placeholder values, do not release this!"
+if (!googleServicesJsonFile.exists()) {
+ val templateFile = project.file("google-services-template.json")
+ templateFile.copyTo(googleServicesJsonFile)
+ // Also write warning message to template file so it shows up in git
+ templateFile.writeText(placeholderWarningMessage)
+}
+if (googleServicesJsonFile.readText().contains("placeholder")) {
+ println(placeholderWarningMessage)
+}
+
plugins {
id("com.android.application")
kotlin("android")
kotlin("kapt")
alias(libs.plugins.compose.compiler)
+ // Firebase Authentication, Crashlytics
+ alias(libs.plugins.google.services)
+ alias(libs.plugins.firebase.crashlytics)
}
-if (project.file("google-services.json").exists()) {
- apply(plugin = "com.google.gms.google-services")
-}
-// Note: need to apply Crashlytics after Google services plugin,
-// above conditional apply doesn't work inside plugins block.
-apply(plugin = "com.google.firebase.crashlytics")
-
val sgCompileSdk: Int by rootProject.extra
val sgMinSdk: Int by rootProject.extra
val sgTargetSdk: Int by rootProject.extra
@@ -213,6 +223,7 @@ dependencies {
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.coordinatorlayout)
implementation(libs.androidx.preference)
+ implementation(libs.androidx.datastore)
implementation(libs.androidx.swiperefreshlayout)
// Compose
@@ -226,6 +237,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 +285,16 @@ dependencies {
exclude(group = "org.threeten", module = "threetenbp") // using ThreeTenABP instead
}
+ // Firebase Authentication
+ implementation(libs.firebase.auth)
+ implementation(libs.androidx.credentials)
+ implementation(libs.androidx.credentials.play)
+ implementation(libs.googleid)
+ 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/app/google-services-template.json b/app/google-services-template.json
new file mode 100644
index 0000000000..bb285f2221
--- /dev/null
+++ b/app/google-services-template.json
@@ -0,0 +1,93 @@
+{
+ "project_info": {
+ "project_number": "123456789012",
+ "firebase_url": "https://project-id-placeholder.firebaseio.com",
+ "project_id": "project-id-placeholder",
+ "storage_bucket": "project-id-placeholder.appspot.com"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "app-id-placeholder",
+ "android_client_info": {
+ "package_name": "com.battlelancer.seriesguide"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "client-id-placeholder",
+ "client_type": 1,
+ "android_info": {
+ "package_name": "com.battlelancer.seriesguide",
+ "certificate_hash": "certificate-hash-placeholder"
+ }
+ },
+ {
+ "client_id": "client-id-placeholder",
+ "client_type": 1,
+ "android_info": {
+ "package_name": "com.battlelancer.seriesguide",
+ "certificate_hash": "certificate-hash-placeholder"
+ }
+ },
+ {
+ "client_id": "client-id-placeholder",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "api-key-placeholder"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "client-id-placeholder",
+ "client_type": 3
+ }
+ ]
+ }
+ }
+ },
+ {
+ "client_info": {
+ "mobilesdk_app_id": "app-id-placeholder",
+ "android_client_info": {
+ "package_name": "com.uwetrottmann.seriesguide.amzn"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "client-id-placeholder",
+ "client_type": 1,
+ "android_info": {
+ "package_name": "com.uwetrottmann.seriesguide.amzn",
+ "certificate_hash": "certificate-hash-placeholder"
+ }
+ },
+ {
+ "client_id": "client-id-placeholder",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "api-key-placeholder"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "client-id-placeholder",
+ "client_type": 3
+ }
+ ]
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/app/lint.xml b/app/lint.xml
index 9767742157..d54ebeb46d 100644
--- a/app/lint.xml
+++ b/app/lint.xml
@@ -21,4 +21,11 @@
+
+
\ No newline at end of file
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 @@
+
package com.battlelancer.seriesguide.backend
@@ -13,8 +13,12 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isGone
import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
import com.battlelancer.seriesguide.R
import com.battlelancer.seriesguide.SgApp
+import com.battlelancer.seriesguide.backend.auth.AuthException
+import com.battlelancer.seriesguide.backend.auth.FirebaseAuthActivity
+import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI
import com.battlelancer.seriesguide.backend.settings.HexagonSettings
import com.battlelancer.seriesguide.billing.BillingTools
import com.battlelancer.seriesguide.databinding.FragmentCloudSetupBinding
@@ -24,14 +28,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
@@ -48,9 +50,6 @@ class CloudSetupFragment : Fragment() {
private var binding: FragmentCloudSetupBinding? = null
- private var snackbar: Snackbar? = null
-
- private var signInAccount: FirebaseUser? = null
private lateinit var hexagonTools: HexagonTools
override fun onCreate(savedInstanceState: Bundle?) {
@@ -77,15 +76,13 @@ class CloudSetupFragment : Fragment() {
(buttonCloudSignIn as MaterialButton).setIconResource(R.drawable.ic_awesome_black_24dp)
}
setOnClickListener {
- // restrict access to supporters
- if (BillingTools.hasAccessToPaidFeatures()) {
- startHexagonSetup()
- } else {
- BillingTools.advertiseSubscription(requireContext())
- }
+ signInOrStartSetupOrAdvertiseSubscription()
}
}
- buttonCloudSignOut.setOnClickListener { signOut() }
+
+ buttonCloudSignOut.setOnClickListener {
+ signOut()
+ }
textViewCloudWarnings.setOnClickListener {
// link to trakt account activity which has details about disabled features
@@ -93,23 +90,17 @@ class CloudSetupFragment : Fragment() {
}
buttonCloudRemoveAccount.setOnClickListener {
- if (RemoveCloudAccountDialogFragment().safeShow(
- parentFragmentManager,
- "remove-cloud-account"
- )) {
- setProgressVisible(true)
- }
+ removeCloudAccount()
}
- updateViews()
- setProgressVisible(true)
+ setProgressVisible(false)
syncStatusCloud.visibility = View.GONE
}
}
override fun onStart() {
super.onStart()
- checkSignedIn()
+ updateViewsWithCloudState()
}
override fun onResume() {
@@ -127,163 +118,123 @@ class CloudSetupFragment : Fragment() {
binding = null
}
- @Subscribe(threadMode = ThreadMode.MAIN)
- fun onEventMainThread(@Suppress("UNUSED_PARAMETER") event: RemoveCloudAccountDialogFragment.CanceledEvent) {
- setProgressVisible(false)
- }
-
- @Subscribe(threadMode = ThreadMode.MAIN)
- fun onEventMainThread(event: RemoveCloudAccountDialogFragment.AccountRemovedEvent) {
- event.handle(requireContext())
- setProgressVisible(false)
- updateViews()
- }
-
@Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
fun onEvent(event: SyncProgress.SyncEvent) {
binding?.syncStatusCloud?.setProgress(event)
}
- /**
- * If there is a signed in account, displays it.
- */
- private fun checkSignedIn() {
- val firebaseUser = FirebaseAuth.getInstance().currentUser
- // If not signed in account will be null.
- changeAccount(firebaseUser, null)
- }
-
- /**
- * If the Firebase account is not null, saves it and auto-starts setup if Cloud is not
- * enabled or the account needs validation.
- * On sign-in failure with error message (so was not canceled) sets should validate account flag.
- */
- private fun changeAccount(account: FirebaseUser?, errorIfNull: String?) {
- val signedIn = account != null
- if (signedIn) {
- Timber.i("Signed in to Cloud.")
- signInAccount = account
- } else {
- signInAccount = null
- errorIfNull?.let {
- HexagonSettings.setShouldValidateAccount(requireContext(), true)
- showSnackbar(getString(R.string.hexagon_signin_fail_format, it))
+ private fun signInOrStartSetupOrAdvertiseSubscription() {
+ if (BillingTools.hasAccessToPaidFeatures()) {
+ if (!startSetupIfSignedIn()) {
+ signIn()
}
+ } else {
+ BillingTools.advertiseSubscription(requireContext())
}
+ }
- setProgressVisible(false)
- updateViews()
+ private fun signIn() {
+ setProgressVisible(true)
- if (signedIn && BillingTools.hasAccessToPaidFeatures()) {
- if (!HexagonSettings.isEnabled(requireContext())
- || HexagonSettings.shouldValidateAccount(requireContext())) {
- Timber.i("Auto-start Cloud setup.")
- startHexagonSetup()
- }
- }
+ val intent = FirebaseAuthActivity.createIntent(requireContext())
+ signInWithFirebase.launch(intent)
}
private val signInWithFirebase =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
+ setProgressVisible(false)
if (result.resultCode == Activity.RESULT_OK) {
- changeAccount(FirebaseAuth.getInstance().currentUser, null)
+ startSetupIfSignedIn()
} else {
- val response = IdpResponse.fromResultIntent(result.data)
- if (response == 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)
- }
-
- ErrorCodes.PLAY_SERVICES_UPDATE_CANCELLED -> {
- // 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)
- )
- }
- }
+ // Don't display any error message, the auth UI would already have,
+ // but log and if needed report the final error.
+ 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
+ Timber.i("Sign in cancelled")
+ } else {
+ if (error is AuthException.InvalidCredentialsException
+ && !hexagonTools.isGoogleSignInAvailable) {
+ // Note: If trying to sign in with an account that is configured for
+ // Google Sign-In (such as one created on other device), can't sign in
+ // because Play Services is not available.
+ // The user or an admin would have to use the reset password function to
+ // change the account back to email sign-in.
+ Timber.e("Account requires Google Sign-In, but Google Play Services not available")
} else {
- errorMessage = "Unknown error"
- Errors.logAndReportHexagonAuthError(
- HexagonAuthError(ACTION_SIGN_IN, errorMessage)
- )
+ // Log and report other reasons signing in fails
+ Timber.e(error, "Failed to sign in (last error)")
+ Errors.reportHexagonAuthError(ACTION_SIGN_IN, error)
}
- changeAccount(null, errorMessage)
+ updateViewsWithCloudState()
}
}
}
- 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()
-
- signInWithFirebase.launch(intent)
- }
-
private fun signOut() {
if (HexagonSettings.shouldValidateAccount(requireContext())) {
// Account needs to be repaired, so can't sign out, just disable Cloud
hexagonTools.removeAccountAndSetDisabled()
- updateViews()
+ updateViewsWithCloudState()
} else {
setProgressVisible(true)
- AuthUI.getInstance().signOut(requireContext()).addOnCompleteListener {
- Timber.i("Signed out.")
- signInAccount = null
- hexagonTools.removeAccountAndSetDisabled()
- if (this@CloudSetupFragment.isAdded) {
- setProgressVisible(false)
- updateViews()
- }
+ val context = requireContext().applicationContext
+ viewLifecycleOwner.lifecycleScope.launch {
+ // Launch on app scope to guarantee state in HexagonTools is updated even if this
+ // fragment is destroyed.
+ SgApp.coroutineScope.launch {
+ try {
+ FirebaseAuthUI.getInstance().signOut(context)
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to sign out")
+ Errors.reportHexagonAuthError(ACTION_SIGN_OUT, e)
+ }
+ Timber.i("Signed out.")
+ hexagonTools.removeAccountAndSetDisabled()
+ }.join()
+
+ // If views aren't destroyed, yet, update them
+ setProgressVisible(false)
+ updateViewsWithCloudState()
}
}
}
- private fun updateViews() {
+ private fun removeCloudAccount() {
+ if (RemoveCloudAccountDialogFragment().safeShow(
+ parentFragmentManager,
+ "remove-cloud-account"
+ )) {
+ setProgressVisible(true)
+ }
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ fun onEventMainThread(@Suppress("UNUSED_PARAMETER") event: RemoveCloudAccountDialogFragment.CanceledEvent) {
+ setProgressVisible(false)
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ fun onEventMainThread(event: RemoveCloudAccountDialogFragment.AccountRemovedEvent) {
+ event.handle(requireContext())
+ setProgressVisible(false)
+ updateViewsWithCloudState()
+ }
+
+ private fun updateViewsWithCloudState() {
if (HexagonSettings.isEnabled(requireContext())) {
// hexagon enabled...
binding?.textViewCloudUser?.text = HexagonSettings.getAccountName(requireContext())
@@ -305,7 +256,7 @@ class CloudSetupFragment : Fragment() {
)
}
} else {
- // did try to setup, but failed?
+ // did try setup, but failed?
if (!HexagonSettings.hasCompletedSetup(requireContext())) {
// show error message
binding?.textViewCloudDescription?.setText(R.string.hexagon_setup_incomplete)
@@ -346,53 +297,47 @@ class CloudSetupFragment : Fragment() {
}
}
- private fun showSnackbar(message: CharSequence) {
- dismissSnackbar()
- snackbar = Snackbar.make(requireView(), message, Snackbar.LENGTH_INDEFINITE).also {
- it.show()
+ private fun startSetupIfSignedIn(): Boolean {
+ val account = FirebaseAuth.getInstance().currentUser
+ val signedIn = account != null
+ if (account != null) {
+ Timber.i("Authenticated with Firebase")
+ startHexagonSetup(account)
}
+ return signedIn
}
- private fun dismissSnackbar() {
- snackbar?.dismiss()
- }
-
- private fun startHexagonSetup() {
- dismissSnackbar()
+ private fun startHexagonSetup(account: FirebaseUser) {
setProgressVisible(true)
- val signInAccountOrNull = signInAccount
- if (signInAccountOrNull == null) {
- signIn()
- } else {
- Timber.i("Setting up Hexagon...")
- // set setup incomplete flag
- HexagonSettings.setSetupIncomplete(requireContext())
-
- // validate account data
- if (signInAccountOrNull.email.isNullOrEmpty()) {
- Timber.d("Setting up Hexagon...FAILURE_AUTH")
- // show setup incomplete message + error toast
- view?.let {
- Snackbar.make(it, R.string.hexagon_setup_fail_auth, Snackbar.LENGTH_LONG)
- .show()
- }
- } else if (hexagonTools.setAccountAndEnabled(signInAccountOrNull)) {
- // schedule full sync
- Timber.d("Setting up Hexagon...SUCCESS_SYNC_REQUIRED")
- SgSyncAdapter.requestSyncFullImmediate(requireContext(), false)
- HexagonSettings.setSetupCompleted(requireContext())
- } else {
- // Do not set completed, will show setup incomplete message.
- Timber.d("Setting up Hexagon...FAILURE")
+ Timber.i("Setting up Hexagon...")
+ // set setup incomplete flag
+ HexagonSettings.setSetupIncomplete(requireContext())
+
+ // validate account data
+ if (account.email.isNullOrEmpty()) {
+ Timber.d("Setting up Hexagon...FAILURE_AUTH")
+ // show setup incomplete message + error toast
+ view?.let {
+ Snackbar.make(it, R.string.hexagon_setup_fail_auth, Snackbar.LENGTH_LONG)
+ .show()
}
-
- setProgressVisible(false)
- updateViews()
+ } else if (hexagonTools.setAccountAndEnabled(account)) {
+ // schedule full sync
+ Timber.d("Setting up Hexagon...SUCCESS_SYNC_REQUIRED")
+ SgSyncAdapter.requestSyncFullImmediate(requireContext(), false)
+ HexagonSettings.setSetupCompleted(requireContext())
+ } else {
+ // Do not set completed, will show setup incomplete message.
+ Timber.d("Setting up Hexagon...FAILURE")
}
+
+ setProgressVisible(false)
+ updateViewsWithCloudState()
}
companion object {
private const val ACTION_SIGN_IN = "sign-in"
+ private const val ACTION_SIGN_OUT = "sign-out"
}
}
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..ad24f2af76 100644
--- a/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonAuthError.kt
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonAuthError.kt
@@ -1,42 +1,28 @@
-// Copyright 2023 Uwe Trottmann
// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright © 2019 Uwe Trottmann
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
-
/**
* Error for tracking Cloud Sign-In failures.
*/
class HexagonAuthError(
val action: String,
failure: String,
- cause: Throwable?,
- val statusCode: Int?
+ cause: Throwable?
) : Throwable("$action: $failure", cause) {
- constructor(action: String, failure: String) : this(action, failure, null, null)
-
- fun isSignInRequiredError(): Boolean {
- return cause is ApiException && statusCode == CommonStatusCodes.SIGN_IN_REQUIRED
- }
-
companion object {
/**
- * Extracts status code from ApiException, FirebaseUiException.
+ * Uses the given action and message of the throwable to build a [HexagonAuthError].
*/
- @JvmStatic
fun build(action: String, throwable: Throwable): HexagonAuthError {
- val message = throwable.message ?: ""
- val statusCode = when (throwable) {
- is FirebaseUiException -> throwable.errorCode
- is ApiException -> throwable.statusCode
- else -> null
- }
- return HexagonAuthError(action, message, throwable, statusCode)
+ return HexagonAuthError(
+ action,
+ throwable.message ?: "",
+ throwable
+ )
}
}
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..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,10 +10,8 @@ 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.firebase.ui.auth.AuthUI
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.mapError
@@ -21,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
@@ -35,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
@@ -58,17 +53,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
@@ -190,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 {
@@ -216,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
@@ -309,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
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..5039c9478e 100644
--- a/app/src/main/java/com/battlelancer/seriesguide/backend/RemoveCloudAccountDialogFragment.kt
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/RemoveCloudAccountDialogFragment.kt
@@ -1,5 +1,5 @@
-// Copyright 2023 Uwe Trottmann
// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright © 2014 Uwe Trottmann
package com.battlelancer.seriesguide.backend
@@ -12,11 +12,9 @@ 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.backend.auth.AuthException
+import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI
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
@@ -26,7 +24,6 @@ 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
@@ -59,7 +56,6 @@ class RemoveCloudAccountDialogFragment : AppCompatDialogFragment() {
class RemoveHexagonAccountTask(context: Context) {
- private val context: Context = context.applicationContext
private val hexagonTools: HexagonTools = getServicesComponent(context).hexagonTools()
suspend fun run() {
@@ -71,7 +67,7 @@ class RemoveCloudAccountDialogFragment : AppCompatDialogFragment() {
}
}
- private fun doInBackground(): Boolean {
+ private suspend fun doInBackground(): Boolean {
// remove account from hexagon
try {
val accountService = hexagonTools.buildAccountService()
@@ -83,23 +79,11 @@ class RemoveCloudAccountDialogFragment : AppCompatDialogFragment() {
}
// 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)
- }
+ FirebaseAuthUI.getInstance().delete()
+ } catch (e: AuthException) {
+ Timber.e(e, "Failed to delete Firebase account")
+ Errors.reportHexagonAuthError(ACTION_REMOVE_ACCOUNT, e)
return false
}
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..ae91b31610
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt
@@ -0,0 +1,438 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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
+
+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
+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, the account doesn't exist or is disabled 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
+ */
+ class InvalidCredentialsException(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * The password provided does not meet the password policy configured in Firebase Authentication
+ * settings.
+ *
+ * This exception is thrown when creating an account or updating a password.
+ *
+ * @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 that can be shown to
+ * the user.
+ */
+ 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 or linking to an existing
+ * account with an email address that is already used by an existing account.
+ *
+ * @property message The detailed error message
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class EmailAlreadyInUseException(
+ message: String,
+ 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 OAuth but an account already exists with that
+ * email using a different provider (like email/password).
+ *
+ * Currently, the auth screen assumes that the existing provider is always email.
+ *
+ * **Important:**
+ * this exception (or rather its cause [FirebaseAuthUserCollisionException]) is **not**
+ * thrown when the new provider is considered trusted by Firebase (notably Google for Gmail
+ * or Workspace emails). Firebase then will just "overwrite" the existing provider and use
+ * the trusted provider for the account.
+ * https://github.com/firebase/FirebaseUI-Android/issues/1180
+ * https://groups.google.com/g/firebase-talk/c/ms_NVQem_Cw/m/8g7BFk1IAAAJ
+ *
+ * @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) {
+ constructor(
+ collisionException: FirebaseAuthUserCollisionException,
+ credential: AuthCredential? = null
+ ) : this(
+ message = collisionException.message
+ ?: ("Account already exists for email '${collisionException.email}' " +
+ "with provider '${collisionException.updatedCredential?.provider}'"),
+ email = collisionException.email,
+ credential = credential,
+ cause = collisionException
+ )
+ }
+
+ /**
+ * When signing in with Google, but according to credential manager no Google account is
+ * available on the device.
+ */
+ class NoGoogleAccountAvailableException(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * An operation was cancelled, such as a coroutine getting cancelled or the user cancelling an
+ * operation.
+ */
+ class AuthCancelledException(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * If an action, like sign-up or deletion, is (temporarily) restricted to
+ * admins server-side (so no user self-service).
+ * https://firebase.google.com/docs/auth/users#user-actions
+ */
+ class AdminRestrictedException(
+ 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)
+
+ /**
+ * 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 {
+ private const val FIREBASE_ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL =
+ "ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL"
+ const val FIREBASE_ERROR_ADMIN_RESTRICTED_OPERATION =
+ "ERROR_ADMIN_RESTRICTED_OPERATION"
+ private const val FIREBASE_ERROR_CREDENTIAL_ALREADY_IN_USE =
+ "ERROR_CREDENTIAL_ALREADY_IN_USE"
+ private const val FIREBASE_ERROR_EMAIL_ALREADY_IN_USE = "ERROR_EMAIL_ALREADY_IN_USE"
+ private const val FIREBASE_ERROR_USER_DISABLED = "ERROR_USER_DISABLED"
+ private const val FIREBASE_ERROR_USER_NOT_FOUND = "ERROR_USER_NOT_FOUND"
+
+ /**
+ * 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.
+ *
+ */
+ @JvmStatic
+ fun from(firebaseException: Exception): AuthException {
+ return when (firebaseException) {
+ // If already an AuthException, return it directly
+ is AuthException -> firebaseException
+
+ // Is a FirebaseAuthInvalidCredentialsException, so handle before
+ is FirebaseAuthWeakPasswordException -> {
+ WeakPasswordException(
+ message = firebaseException.message ?: "Password is too weak",
+ cause = firebaseException,
+ reason = firebaseException.reason
+ )
+ }
+
+ // Handle specific Firebase Auth exceptions first (before general FirebaseException)
+ is FirebaseAuthInvalidCredentialsException -> {
+ InvalidCredentialsException(
+ message = firebaseException.message ?: "Invalid credentials provided",
+ cause = firebaseException
+ )
+ }
+
+ // Don't differentiate in UI between invalid credentials and user not found or
+ // disabled to avoid enumeration.
+ is FirebaseAuthInvalidUserException -> {
+ when (firebaseException.errorCode) {
+ FIREBASE_ERROR_USER_NOT_FOUND -> InvalidCredentialsException(
+ message = firebaseException.message ?: "User not found",
+ cause = firebaseException
+ )
+
+ FIREBASE_ERROR_USER_DISABLED -> InvalidCredentialsException(
+ message = firebaseException.message ?: "User account has been disabled",
+ cause = firebaseException
+ )
+
+ else -> InvalidCredentialsException(
+ message = firebaseException.message ?: "User account error",
+ cause = firebaseException
+ )
+ }
+ }
+
+ is FirebaseAuthUserCollisionException -> {
+ when (firebaseException.errorCode) {
+ FIREBASE_ERROR_EMAIL_ALREADY_IN_USE -> EmailAlreadyInUseException(
+ message = firebaseException.message
+ ?: "Email address is already in use",
+ cause = firebaseException
+ )
+
+ FIREBASE_ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL -> AccountLinkingRequiredException(
+ message = firebaseException.message
+ ?: "Account already exists with different credentials",
+ cause = firebaseException
+ )
+
+ FIREBASE_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 -> {
+ when (firebaseException.errorCode) {
+ FIREBASE_ERROR_ADMIN_RESTRICTED_OPERATION -> {
+ AdminRestrictedException(
+ message = firebaseException.message
+ ?: "This action is restricted to admins",
+ 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/AuthState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt
new file mode 100644
index 0000000000..f126ac1745
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt
@@ -0,0 +1,195 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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
+
+import com.battlelancer.seriesguide.backend.auth.AuthState.Companion.Cancelled
+import com.battlelancer.seriesguide.backend.auth.AuthState.Companion.Idle
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.MultiFactorResolver
+
+/**
+ * Represents the authentication state in Firebase Auth UI.
+ *
+ * This class encapsulates all possible authentication states that can occur during
+ * the authentication flow:
+ *
+ * - [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
+ * - [AuthState.PasswordResetLinkSent] when a password reset link has been sent
+ * - [AuthState.EmailSignInLinkSent] when an email sign in link has been sent
+ *
+ * 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 user The authenticated [FirebaseUser]
+ */
+ class Success(
+ val user: FirebaseUser
+ ) : AuthState()
+
+ /**
+ * 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)"
+ }
+
+ /**
+ * 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"
+ }
+
+ 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..38e2e0ec52
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt
@@ -0,0 +1,196 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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
+
+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.battlelancer.seriesguide.R
+import com.battlelancer.seriesguide.SgApp
+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.theme.AuthUITheme
+import com.battlelancer.seriesguide.backend.auth.ui.screens.FirebaseAuthScreen
+import com.battlelancer.seriesguide.backend.auth.util.EmailLinkConstants
+import com.battlelancer.seriesguide.settings.DisplaySettings
+import com.battlelancer.seriesguide.ui.theme.SeriesGuideTheme
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+/**
+ * Activity that hosts the Firebase authentication flow UI.
+ *
+ * This activity displays a [FirebaseAuthScreen] composable and manages
+ * the authentication flow lifecycle. It automatically finishes when the user
+ * signs in successfully or cancels the flow.
+ *
+ * ```kotlin
+ * private val signInWithFirebase = registerForActivityResult(
+ * ActivityResultContracts.StartActivityForResult()
+ * ) { result: ActivityResult ->
+ * if (result.resultCode == Activity.RESULT_OK) {
+ * // User has signed in
+ * } else {
+ * // User has left, there might have been an error
+ * // Optional: access AuthException
+ * 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
+ * }
+ * }
+ * }
+ * }
+ *
+ * val intent = FirebaseAuthActivity.createIntent(requireContext())
+ * signInWithFirebase.launch(intent)
+ * ```
+ *
+ * **Result Codes:**
+ * - [Activity.RESULT_OK] - User signed in successfully
+ * - [Activity.RESULT_CANCELED] - User has left, an error may have occurred
+ *
+ * **Result Data:**
+ * - [EXTRA_ERROR] - [AuthException] when an error occurs
+ *
+ * **Note:** To get the full user object after successful sign-in, use:
+ * ```kotlin
+ * FirebaseAuth.getInstance().currentUser
+ * ```
+ *
+ * @see FirebaseAuthScreen
+ */
+class FirebaseAuthActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val authUI = FirebaseAuthUI.getInstance()
+ if (savedInstanceState == null) {
+ // Clear any previous error before starting a new sign-in flow
+ authUI.updateAuthState(AuthState.Idle)
+ }
+
+ val hexagonTools = SgApp.getServicesComponent(this).hexagonTools()
+
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ privacyPolicyUrl = getString(R.string.url_privacy)
+ isMfaEnabled = false
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ // As recommended by NIST in 2026
+ // https://www.nist.gov/cybersecurity/how-do-i-create-good-password
+ minimumPasswordLength = 15
+ )
+ )
+ if (hexagonTools.isGoogleSignInAvailable) {
+ provider(
+ AuthProvider.Google(
+ // The string resource is created by the google-services plugin
+ serverClientId = getString(R.string.default_web_client_id),
+ filterByAuthorizedAccounts = false
+ )
+ )
+ }
+ }
+ }
+
+ // 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
+ setResult(RESULT_OK)
+ finish()
+ }
+
+ is AuthState.Cancelled -> {
+ // User canceled the flow
+ setResult(RESULT_CANCELED)
+ finish()
+ }
+
+ is AuthState.Error -> {
+ // Log all errors (final error is also reported by CloudSetupFragment)
+ Timber.e(state.exception)
+ // 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 {
+ SeriesGuideTheme(useDynamicColor = DisplaySettings.isDynamicColorsEnabled(this)) {
+ AuthUITheme(theme = AuthUITheme.fromMaterialTheme()) {
+ FirebaseAuthScreen(
+ authUI = authUI,
+ configuration = configuration,
+ emailLink = emailLink,
+ onSignInSuccess = {
+ // State flow will handle finishing
+ },
+ onSignInFailure = { _ ->
+ // State flow will handle error
+ },
+ onSignInCancelled = {
+ // State flow will handle cancellation
+ }
+ )
+ }
+ }
+ }
+ }
+
+ companion object {
+
+ /**
+ * Intent extra key for [AuthException] on error.
+ */
+ const val EXTRA_ERROR = "seriesguide.auth.error"
+
+ /**
+ * Creates an Intent to launch the Firebase authentication flow.
+ *
+ * @param context Android [Context]
+ * @return Configured [Intent] to start [FirebaseAuthActivity]
+ */
+ fun createIntent(context: Context): Intent {
+ return Intent(context, FirebaseAuthActivity::class.java)
+ }
+ }
+}
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..18fca74869
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt
@@ -0,0 +1,475 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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
+
+import android.content.Context
+import android.content.Intent
+import androidx.annotation.RestrictTo
+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.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
+
+ /**
+ * 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 email link in the provided [Intent.getData].
+ *
+ * See [FirebaseAuth.isSignInWithEmailLink].
+ */
+ fun canHandleIntent(intent: Intent?): Boolean {
+ val link = intent?.data ?: return false
+ return auth.isSignInWithEmailLink(link.toString())
+ }
+
+ /**
+ * Creates a [Flow] that emits [AuthState] changes.
+ *
+ * This flow observes changes to the internal authentication state and emits appropriate
+ * [AuthState] objects. See [AuthState] for all possible states.
+ *
+ * The initial state depends on if there is a [FirebaseAuth.getCurrentUser]:
+ * - [AuthState.RequiresEmailVerification] if there is one using the [Provider.EMAIL] provider,
+ * but the email needs to be verified (Note: temporarily disabled)
+ * - [AuthState.Success] if there is one in all other cases
+ * - [AuthState.Idle] if there is none
+ *
+ * **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
+ * }
+ * }
+ * }
+ * ```
+ */
+ fun authStateFlow(): Flow {
+ // Create a flow from FirebaseAuth state listener
+ val firebaseAuthFlow = callbackFlow {
+ // Set initial state based on current FirebaseAuth user
+ val initialState = auth.currentUser.toAuthState()
+ trySend(initialState)
+
+ // Listen to changes in the FirebaseAuth user authentication state
+ val authStateListener = AuthStateListener { firebaseAuth ->
+ val state = firebaseAuth.currentUser.toAuthState()
+ trySend(state)
+ }
+ auth.addAuthStateListener(authStateListener)
+ // Stop listening 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()
+ }
+
+ private fun FirebaseUser?.toAuthState(): AuthState {
+ return if (this != null) {
+ // Temporarily remove email verification UI as the signed-in check in
+ // CloudSetupFragment and HexagonTools don't enforce it (could probably back out
+ // and would be signed in) and the Firebase account is created regardless.
+ // Check if email verification is required
+ @Suppress("KotlinConstantConditions", "KotlinUnreachableCode")
+ if (false &&
+ !isEmailVerified &&
+ email != null &&
+ providerData.any { it.providerId == Provider.EMAIL.id }
+ ) {
+ AuthState.RequiresEmailVerification(
+ user = this,
+ email = email!!
+ )
+ } else {
+ AuthState.Success(user = this)
+ }
+ } else {
+ AuthState.Idle
+ }
+ }
+
+ /**
+ * Updates the internal authentication state to [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).
+ */
+ fun updateAuthState(state: AuthState) {
+ _authStateFlow.value = state
+ }
+
+ /**
+ * Signs out the current user and if successful sets authentication state to [AuthState.Idle].
+ *
+ * This method signs out the user from Firebase Auth, if signed in with Google calls
+ * [signOutFromGoogle] and updates the auth state flow to reflect the change. The operation is
+ * performed partially asynchronous 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 For [signOutFromGoogle] to create [androidx.credentials.CredentialManager]
+ * @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)
+ }
+
+ // 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 if successful sets authentication state to
+ * [AuthState.Idle].
+ *
+ * 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
+ * }
+ * ```
+ *
+ * @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.AdminRestrictedException if deleting accounts is not allowed
+ * @throws AuthException.UnknownException for other errors
+ * @since 10.0.0
+ */
+ suspend fun delete() {
+ try {
+ val currentUser = auth.currentUser
+ ?: throw AuthException.UnknownException(
+ 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
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/README.md b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/README.md
new file mode 100644
index 0000000000..1336ae27a0
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/README.md
@@ -0,0 +1,49 @@
+# SeriesGuide Cloud Authentication
+
+Currently, using Firebase Authentication to obtain an email address to use for Cloud.
+
+## Documentation
+
+- [Firebase Authentication](https://firebase.google.com/docs/auth/android/start)
+
+- [Firebase for Android Auth API](https://firebase.google.com/docs/reference/android/com/google/firebase/auth/package-summary)
+
+ - [FirebaseAuth](https://firebase.google.com/docs/reference/android/com/google/firebase/auth/FirebaseAuth)
+
+ - [FirebaseAuthException](https://firebase.google.com/docs/reference/android/com/google/firebase/auth/FirebaseAuthException)
+
+## Providers
+
+Supported to sign up using:
+
+- [email and password](https://firebase.google.com/docs/auth/android/password-auth)
+- [Google Sign-In](https://firebase.google.com/docs/auth/android/google-signin)
+
+### Email and password
+
+To sign up, need to specify a display name, email address and password.
+Currently, doesn't require to verify the email address through a verification email.
+Currently, doesn't support multi-factor authentication.
+
+To reset the password, an email is sent with a link to a password reset website.
+
+Templates and sender name and address for the emails as well as password requirements (currently
+default) are configured in the Firebase Authentication settings.
+
+The UI currently requires the password to be at least 15 characters,
+see [FirebaseAuthActivity](FirebaseAuthActivity.kt).
+
+### Google Sign-In
+
+To sign up, need to have a Google account set up in Android.
+
+> [!IMPORTANT]
+> Note that currently when signing in using Google to an existing email-based account and the email
+address is provided by Gmail, Firebase **converts the account to Google Sign-In**. So signing in
+using email (like on another device without Play Services) will then fail unless the password is
+reset.
+>
+> This is because Firebase considers Google the trusted provider for these email addresses. See:
+>
+> * https://github.com/firebase/FirebaseUI-Android/issues/1180
+> * https://groups.google.com/g/firebase-talk/c/ms_NVQem_Cw/m/8g7BFk1IAAAJ
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..77a21e0112
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt
@@ -0,0 +1,142 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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
+
+import android.content.Context
+import androidx.annotation.DrawableRes
+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.google.firebase.auth.ActionCodeSettings
+
+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 stringProvider: AuthUIStringProvider? = null
+ var isCredentialManagerEnabled: Boolean = true
+ var isMfaEnabled: Boolean = true
+ var privacyPolicyUrl: String? = null
+ var logo: Int? = null
+ var passwordResetActionCodeSettings: ActionCodeSettings? = null
+ 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 }}"
+ }
+
+ // 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()
+ is AuthProvider.Google -> provider.validate()
+ is AuthProvider.GenericOAuth -> provider.validate()
+ }
+ }
+
+ return AuthUIConfiguration(
+ context = context,
+ providers = providers.toList(),
+ stringProvider = stringProvider ?: DefaultAuthUIStringProvider(context),
+ isCredentialManagerEnabled = isCredentialManagerEnabled,
+ isMfaEnabled = isMfaEnabled,
+ privacyPolicyUrl = privacyPolicyUrl,
+ logo = logo,
+ passwordResetActionCodeSettings = passwordResetActionCodeSettings,
+ 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(),
+
+ /**
+ * A custom provider for localized strings.
+ */
+ val stringProvider: AuthUIStringProvider = DefaultAuthUIStringProvider(context),
+
+ /**
+ * 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,
+
+ /**
+ * The URL for the privacy policy.
+ */
+ val privacyPolicyUrl: String? = null,
+
+ /**
+ * The logo to display on the authentication screens.
+ */
+ @DrawableRes
+ val logo: Int? = null,
+
+ /**
+ * Configuration for sending email reset link.
+ */
+ val passwordResetActionCodeSettings: ActionCodeSettings? = null,
+
+ /**
+ * 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..3ef01adb32
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt
@@ -0,0 +1,27 @@
+// 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
+
+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..2967b57509
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt
@@ -0,0 +1,34 @@
+// 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
+
+/**
+ * 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.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.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..fceb759084
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt
@@ -0,0 +1,19 @@
+// 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
+
+/**
+ * Represents the different Multi-Factor Authentication (MFA) factors that can be used
+ * for enrollment and verification.
+ */
+enum class MfaFactor {
+ /**
+ * 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..51a112ab99
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt
@@ -0,0 +1,116 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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
+
+import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider
+
+/**
+ * A set of validation rules that can be applied to a password when using
+ * [com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider.Email].
+ */
+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/AuthProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt
new file mode 100644
index 0000000000..07589d60bc
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt
@@ -0,0 +1,453 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.content.Context
+import android.net.Uri
+import androidx.annotation.DrawableRes
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.graphics.Color
+import androidx.credentials.ClearCredentialStateRequest
+import androidx.credentials.CredentialManager
+import androidx.credentials.GetCredentialRequest
+import androidx.credentials.exceptions.GetCredentialException
+import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfigurationDsl
+import com.battlelancer.seriesguide.backend.auth.configuration.PasswordRule
+import com.battlelancer.seriesguide.backend.auth.util.ContinueUrlBuilder
+import com.google.android.libraries.identity.googleid.GetGoogleIdOption
+import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
+import com.google.firebase.auth.ActionCodeSettings
+import com.google.firebase.auth.AuthCredential
+import com.google.firebase.auth.EmailAuthProvider
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.GoogleAuthProvider
+import com.google.firebase.auth.UserProfileChangeRequest
+import com.google.firebase.auth.actionCodeSettings
+import kotlinx.coroutines.tasks.await
+import timber.log.Timber
+
+@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
+) {
+ GOOGLE(GoogleAuthProvider.PROVIDER_ID, providerName = "Google"),
+ EMAIL(EmailAuthProvider.PROVIDER_ID, providerName = "Email");
+
+ 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.
+ *
+ * This is required for security when upgrading anonymous users, but as this doesn't support
+ * anonymous accounts defaults to false.
+ */
+ val isEmailLinkForceSameDeviceEnabled: Boolean = false,
+
+ /**
+ * 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.
+ *
+ * This should match or exceed the value configured in the Firebase Authentication password
+ * policy settings. Otherwise, creating an account will fail.
+ *
+ * Additional rules can be defined with [passwordValidationRules].
+ */
+ val minimumPasswordLength: Int = 6,
+
+ /**
+ * A list of password validation rules in addition to [minimumPasswordLength].
+ *
+ * This should match or exceed the rules configured in the Firebase Authentication password
+ * policy settings. Otherwise, creating an account will fail.
+ */
+ val additionalPasswordValidationRules: List = emptyList(),
+ ) : AuthProvider(providerId = Provider.EMAIL.id, providerName = Provider.EMAIL.providerName) {
+
+ /**
+ * At least a [PasswordRule.MinimumLength] rule using [minimumPasswordLength] and any
+ * [additionalPasswordValidationRules].
+ */
+ val passwordValidationRules: List = buildList {
+ // Add minimum length rule first to avoid misleading error messages if the password is
+ // empty
+ add(PasswordRule.MinimumLength(minimumPasswordLength))
+ addAll(additionalPasswordValidationRules)
+ }
+
+ companion object {
+ const val SESSION_ID_LENGTH = 10
+ }
+
+ internal fun validate() {
+ 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."
+ }
+ }
+
+ check(
+ additionalPasswordValidationRules
+ .find { it is PasswordRule.MinimumLength } == null
+ ) {
+ "Must not add a MinimumLength rule, set minimumPasswordLength instead"
+ }
+ }
+
+ /**
+ * Using this providers [emailLinkActionCodeSettings] builds [ActionCodeSettings] for
+ * sending an email link for sign-in.
+ */
+ internal fun buildActionCodeSettings(
+ sessionId: String,
+ credentialForLinking: AuthCredential? = null,
+ ): ActionCodeSettings {
+ requireNotNull(emailLinkActionCodeSettings) {
+ "ActionCodeSettings is required for email link sign in"
+ }
+
+ val continueUrl = continueUrl(emailLinkActionCodeSettings.url) {
+ appendSessionId(sessionId)
+ 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()
+
+ }
+
+ /**
+ * Google Sign-In provider configuration.
+ */
+ class Google(
+
+ /**
+ * The OAuth 2.0 client ID for your server.
+ *
+ * When using the google-services plugin, this is the default_web_client_id string.
+ */
+ val 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,
+ customParameters = customParameters
+ ) {
+ internal fun validate() {
+ require(serverClientId.isNotBlank()) {
+ "Server client ID cannot be blank."
+ }
+ }
+
+ /**
+ * 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 Credential Manager flow for Google Sign-In.
+ * @suppress
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ interface CredentialManagerProvider {
+
+ /**
+ * @throws GetCredentialException
+ * @throws com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
+ * @see CredentialManager.getCredential
+ */
+ suspend fun getGoogleCredential(
+ context: Context,
+ credentialManager: CredentialManager,
+ serverClientId: String,
+ filterByAuthorizedAccounts: Boolean,
+ autoSelectEnabled: Boolean,
+ ): GoogleSignInResult
+
+ suspend fun clearCredentialState(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 {
+ // https://developers.google.com/identity/android-credential-manager/android/reference/com/google/android/libraries/identity/googleid/GetGoogleIdOption.Builder
+ val googleIdOption = GetGoogleIdOption.Builder()
+ .setServerClientId(serverClientId)
+ .setFilterByAuthorizedAccounts(filterByAuthorizedAccounts)
+ .setAutoSelectEnabled(autoSelectEnabled)
+ .build()
+
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(googleIdOption)
+ .build()
+
+ val result = credentialManager.getCredential(context, request)
+
+ // According to https://developer.android.com/identity/sign-in/credential-manager-siwg-implementation#create-shared
+ // createFrom may throw GoogleIdTokenParsingException if the googleid library is
+ // incompatible.
+ val googleIdTokenCredential =
+ GoogleIdTokenCredential.createFrom(result.credential.data)
+ val firebaseCredential =
+ GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null)
+
+ return GoogleSignInResult(
+ credential = firebaseCredential,
+ idToken = googleIdTokenCredential.idToken,
+ displayName = googleIdTokenCredential.displayName,
+ photoUrl = googleIdTokenCredential.profilePictureUri,
+ )
+ }
+
+ override suspend fun clearCredentialState(credentialManager: CredentialManager) {
+ credentialManager.clearCredentialState(ClearCredentialStateRequest())
+ }
+ }
+ }
+
+ /**
+ * 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.
+ */
+ @DrawableRes
+ val buttonIcon: Int?,
+
+ /**
+ * 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 {
+
+ /**
+ * 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
+ Timber.e(e, "Error updating profile")
+ }
+ }
+ }
+}
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..5117bc404f
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,667 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.content.Context
+import android.net.Uri
+import com.battlelancer.seriesguide.backend.auth.AuthException
+import com.battlelancer.seriesguide.backend.auth.AuthException.EmailAlreadyInUseException
+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.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.EmailLinkParser
+import com.battlelancer.seriesguide.backend.auth.util.EmailLinkPersistenceManager
+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.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.FirebaseAuthInvalidUserException
+import com.google.firebase.auth.FirebaseAuthMultiFactorException
+import com.google.firebase.auth.FirebaseAuthUserCollisionException
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.tasks.await
+import timber.log.Timber
+
+private const val LOG_TAG = "EmailAuthProvider"
+
+/**
+ * Creates an account using [email] and [password].
+ *
+ * **Flow:**
+ * - Check if new accounts are allowed by the [config]
+ * - Validate password against [provider] [AuthProvider.Email.passwordValidationRules]
+ * - Create new account with `createUserWithEmailAndPassword`
+ * - Save credentials, unless [config] disables it
+ * - Merge display [name] into user profile
+ *
+ * @param context Android [Context] for saving credentials
+ *
+ * @return [AuthResult] containing the newly created or linked user, or null if failed
+ *
+ * @throws AuthException.AdminRestrictedException 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
+ *
+ */
+internal suspend fun FirebaseAuthUI.createUserWithEmailAndPassword(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Email,
+ name: String?,
+ email: String,
+ password: String,
+): AuthResult? {
+ try {
+ // This should never get called if isNewAccountsAllowed is false.
+ // Note: this only checks the configuration in this app. Sign-ups can also be turned off
+ // server side in which case the API call below should throw FirebaseAuthException with
+ // ERROR_ADMIN_RESTRICTED_OPERATION which is then wrapped into a AdminRestrictedException.
+ if (!provider.isNewAccountsAllowed) {
+ throw AuthException.AdminRestrictedException("Called despite provider.isNewAccountsAllowed = false")
+ }
+
+ // Validate password against rules
+ for (rule in provider.passwordValidationRules) {
+ if (!rule.isValid(password)) {
+ val reason = rule.getErrorMessage(config.stringProvider)
+ throw AuthException.WeakPasswordException(
+ message = "Password does not meet rules: $reason",
+ reason = reason
+ )
+ }
+ }
+
+ updateAuthState(AuthState.Loading("Creating user..."))
+ val result = 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)
+ }
+ }
+
+ result?.let {
+ saveCredentialAndSignInPreference(
+ context, config, provider.providerId, email, password
+ )
+ }
+
+ updateAuthState(AuthState.Idle)
+ return result
+ } catch (e: FirebaseAuthUserCollisionException) {
+ // When trying to create an account, failed because an account using this email address
+ // already exists.
+ val authException = EmailAlreadyInUseException(
+ message = e.message ?: "Email address is already in use",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Create 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 [credentialForLinking].
+ *
+ * **Flow:**
+ * - Sign in with email/password
+ * - Save credentials, unless [config] disables it or [skipCredentialSave] is set
+ * - If [credentialForLinking] provided: link it and merge profile
+ *
+ * @param context Android [Context] for saving credentials
+ *
+ * @return [AuthResult] containing the signed-in user, or null if multi-factor auth is required
+ *
+ * @throws AuthException.InvalidCredentialsException if email or password is incorrect or the user
+ * is not found or disabled
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException for network-related failures
+ */
+internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider,
+ email: String,
+ password: String,
+ credentialForLinking: AuthCredential? = null,
+ skipCredentialSave: Boolean = false,
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading("Signing in..."))
+ return 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 ->
+ result?.let {
+ saveCredentialAndSignInPreference(
+ context, config, provider.providerId, email, password, skipCredentialSave
+ )
+ }
+
+ 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
+ }
+}
+
+/**
+ * Saves the password credential to Credential Manager and records the sign-in preference.
+ * As this isn't necessary to complete sign-in, failures are only logged.
+ */
+private suspend fun saveCredentialAndSignInPreference(
+ context: Context,
+ config: AuthUIConfiguration,
+ providerId: String,
+ email: String,
+ password: String,
+ skipCredentialSave: Boolean = false,
+) {
+ // Save credentials to Credential Manager if enabled
+ // Skip if user signed in with a retrieved credential (already saved)
+ if (config.isCredentialManagerEnabled && !skipCredentialSave) {
+ try {
+ val credentialHandler = PasswordCredentialHandler(context)
+ credentialHandler.savePassword(email, password)
+ Timber.d("Password credential saved successfully for: %s", email)
+ } catch (_: PasswordCredentialCancelledException) {
+ // User cancelled - this is fine, don't break the auth flow
+ Timber.d("User cancelled credential save for: %s", email)
+ } catch (e: PasswordCredentialException) {
+ // Failed to save - log but don't break the auth flow
+ Timber.w(e, "Failed to save password credential for: %s", email)
+ }
+ }
+
+ // Save sign-in preference for "Continue as..." feature
+ SignInPreferenceManager.tryToSaveLastSignIn(
+ context = context,
+ providerId = providerId,
+ identifier = email,
+ LOG_TAG
+ )
+}
+
+/**
+ * Signs in with a [credential].
+ *
+ * **Flow:**
+ * - Sign in with credential
+ * - Merge profile information ([displayName], [photoUrl]) into Firebase user
+ *
+ * @return [AuthResult] containing the signed-in user, or null if multi-factor auth is required
+ *
+ * @throws AuthException.InvalidCredentialsException if credential is invalid or expired
+ * @throws AuthException.AccountLinkingRequiredException if account was created with a different
+ * provider, but not if provider is considered trusted by Firebase. See the exception for details.
+ * @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
+ */
+internal suspend fun FirebaseAuthUI.signInWithCredential(
+ credential: AuthCredential,
+ displayName: String? = null,
+ photoUrl: Uri? = null,
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading("Signing in user..."))
+ return 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
+ // Note: this is *not* thrown if the new provider is trusted, see
+ // AccountLinkingRequiredException for details.
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ collisionException = e,
+ credential = credential
+ )
+ 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. Creates [ActionCodeSettings] with session data based on the [AuthProvider.Email.emailLinkActionCodeSettings] of the given [provider]
+ * 3. Sends the email via [com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail] using the action code settings
+ * 4. Saves session data to DataStore for validation when the user clicks the link later
+ * 5. User receives email with a magic link containing the session information
+ * 6. 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.DefaultPersistenceManager.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
+ * - **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 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
+ *
+ * @see signInWithEmailLink
+ * @see EmailLinkPersistenceManager
+ * @see com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail
+ */
+internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
+ context: Context,
+ provider: AuthProvider.Email,
+ email: String,
+ credentialForLinking: AuthCredential?,
+ persistenceManager: PersistenceManager = EmailLinkPersistenceManager.default,
+) {
+ try {
+ updateAuthState(AuthState.Loading("Sending sign in email link..."))
+
+ val sessionId =
+ SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH)
+
+ val actionCodeSettings =
+ provider.buildActionCodeSettings(
+ sessionId = sessionId,
+ credentialForLinking = credentialForLinking
+ )
+
+ auth.sendSignInLinkToEmail(email, actionCodeSettings).await()
+
+ // Save Email to dataStore for use in signInWithEmailLink
+ persistenceManager.saveEmail(context, email, sessionId)
+
+ 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 signs the user in.
+ *
+ * **Flow:**
+ * - User receives email with magic link
+ * - User clicks link, app opens via deep link
+ * - Activity extracts emailLink from Intent.data
+ * - 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 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
+ * - 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`
+ *
+ * @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.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
+ *
+ * @see sendSignInLinkToEmail for sending the initial email link
+ * @see EmailLinkPersistenceManager for session data management
+ */
+internal suspend fun FirebaseAuthUI.signInWithEmailLink(
+ context: Context,
+ 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 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) {
+ 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()
+ }
+
+ // 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(emailLinkCredential)
+ } else {
+ // Linking Flow: Sign in with email link, then link the social credential
+ handleEmailLinkCredentialLinkingFlow(
+ 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(
+ emailLinkCredential: AuthCredential,
+): AuthResult? {
+ return signInWithCredential(emailLinkCredential)
+}
+
+private suspend fun FirebaseAuthUI.handleEmailLinkCredentialLinkingFlow(
+ emailLinkCredential: AuthCredential,
+ storedCredentialForLink: AuthCredential,
+): AuthResult? {
+ // Sign in with email link, then link social credential
+ return 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.
+ *
+ * **Error Handling:**
+ *
+ * - If the email doesn't exist: completes successfully to avoid email enumeration
+ * - 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.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
+ *
+ * @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 (_: FirebaseAuthInvalidUserException) {
+ // To protect against email enumeration don't indicate failure if user doesn't exist
+ 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/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..f178455609
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,233 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.credentials.CredentialManager
+import androidx.credentials.exceptions.GetCredentialCancellationException
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.credentials.exceptions.NoCredentialException
+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.util.EmailLinkPersistenceManager
+import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager
+import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private const val LOG_TAG = "GoogleAuthProvider"
+
+/**
+ * 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.
+ *
+ * **Error Handling:**
+ * - Catches all exceptions and converts them to [AuthException]
+ * - Automatically updates [AuthState.Error] on failures
+ *
+ * @param context Android context for Credential Manager
+ * @param provider Google provider configuration
+ * @return A callback function that initiates Google Sign-In when invoked
+ *
+ * @see signInWithGoogle
+ * @see AuthProvider.Google
+ */
+@Composable
+internal fun FirebaseAuthUI.rememberGoogleSignInHandler(
+ context: Context,
+ provider: AuthProvider.Google,
+): () -> Unit {
+ val coroutineScope = rememberCoroutineScope()
+ return remember(this) {
+ {
+ coroutineScope.launch {
+ try {
+ try {
+ signInWithGoogle(context, provider)
+ } catch (e: CancellationException) {
+ throw AuthException.AuthCancelledException(
+ message = "Google sign-in coroutine interrupted",
+ cause = e
+ )
+ } catch (e: Exception) {
+ // Don't crash on any unhandled exceptions
+ throw AuthException.from(e)
+ }
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Signs in with Google using Credential Manager.
+ *
+ * **Flow:**
+ * 1. Attempts to retrieve Google account credential using Credential Manager
+ * 2. Creates Firebase credential and calls [signInWithCredential]
+ *
+ * **Error Handling:**
+ * - [GoogleIdTokenParsingException]: if creating a GoogleIdTokenCredential in [getGoogleCredential]
+ * failed and the googleid library likely needs to be updated
+ * - [NoCredentialException]: No Google accounts on device
+ * - [GetCredentialException]: User cancellation, configuration errors, or no credentials
+ *
+ * @param context Android context for Credential Manager
+ * @param provider Google provider configuration
+ * @param credentialManagerProvider Provider for Credential Manager flow (for testing)
+ *
+ * @throws AuthException.AuthCancelledException if user cancels, no accounts found or other error
+ * related to Google sign-in
+ * @throws AuthException.AccountLinkingRequiredException if an account using email sign-in exists
+ * after the Google credential was saved using [EmailLinkPersistenceManager].
+ *
+ * @see AuthProvider.Google
+ * @see signInWithCredential
+ */
+internal suspend fun FirebaseAuthUI.signInWithGoogle(
+ context: Context,
+ provider: AuthProvider.Google,
+ credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(),
+) {
+ updateAuthState(AuthState.Loading("Signing in with Google..."))
+
+ // Get Google account from user using credential manager
+ val result =
+ try {
+ try {
+ getGoogleCredential(
+ context = context,
+ provider = provider,
+ credentialManagerProvider = credentialManagerProvider,
+ filterByAuthorizedAccounts = provider.filterByAuthorizedAccounts
+ )
+ } catch (e: NoCredentialException) {
+ if (provider.filterByAuthorizedAccounts) {
+ // Should try again without filter according to
+ // https://developer.android.com/identity/sign-in/credential-manager-siwg-implementation
+ Timber.d("No authorized accounts found, trying again and showing all Google accounts")
+ getGoogleCredential(
+ context = context,
+ provider = provider,
+ credentialManagerProvider = credentialManagerProvider,
+ filterByAuthorizedAccounts = false
+ )
+ } else {
+ throw e // let outer try-catch handle it
+ }
+ }
+ } catch (e: NoCredentialException) {
+ // Display error message, stay on picker screen
+ throw AuthException.NoGoogleAccountAvailableException(
+ "Google sign-in failed: no Google account available", e
+ )
+ } catch (e: GetCredentialCancellationException) {
+ // Display no error message, stay on picker screen
+ throw AuthException.AuthCancelledException(
+ "Google sign-in cancelled by user", e
+ )
+ } catch (e: GetCredentialException) {
+ // Display no error message, stay on picker screen
+ throw AuthException.AuthCancelledException(
+ "Google sign-in failed due to other", e
+ )
+ }
+
+ // Sign in using the Google account info
+ try {
+ signInWithCredential(
+ credential = result.credential,
+ displayName = result.displayName,
+ photoUrl = result.photoUrl,
+ )
+ } catch (e: AuthException.AccountLinkingRequiredException) {
+ // Account collision occurred - save credential for linking after email link sign-in
+ // This happens when a user tries to sign in but an email link account exists
+ EmailLinkPersistenceManager.default.saveCredentialForLinking(
+ context = context,
+ providerType = provider.providerId,
+ idToken = result.idToken,
+ accessToken = null
+ )
+ // Re-throw to let UI handle the account linking flow
+ throw e
+ }
+
+ // Save sign-in preference for "Continue as..." feature
+ val user = auth.currentUser
+ val identifier = user?.email
+ if (identifier != null) {
+ SignInPreferenceManager.tryToSaveLastSignIn(
+ context = context,
+ providerId = provider.providerId,
+ identifier = identifier,
+ LOG_TAG
+ )
+ }
+}
+
+/**
+ * @throws GetCredentialException
+ * @see CredentialManager.getCredential
+ */
+private suspend fun FirebaseAuthUI.getGoogleCredential(
+ context: Context,
+ provider: AuthProvider.Google,
+ credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider,
+ filterByAuthorizedAccounts: Boolean
+): AuthProvider.Google.GoogleSignInResult {
+ return (testCredentialManagerProvider ?: credentialManagerProvider)
+ .getGoogleCredential(
+ context = context,
+ credentialManager = CredentialManager.create(context),
+ serverClientId = provider.serverClientId,
+ filterByAuthorizedAccounts = filterByAuthorizedAccounts,
+ autoSelectEnabled = provider.autoSelectEnabled
+ )
+}
+
+/**
+ * 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(
+ credentialManager = CredentialManager.create(context)
+ )
+ } catch (e: Exception) {
+ Timber.e(e, "Error during Google sign out")
+ }
+}
\ 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..4ea85b23b4
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,219 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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 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 com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration
+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
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+private const val LOG_TAG = "OAuthProvider"
+
+/**
+ * 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,
+ 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.
+ *
+ * **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. Performs normal sign-in
+ * 3. Updates auth state to Idle on success
+ *
+ * **Error Handling:**
+ * - [AuthException.AuthCancelledException]: User cancelled OAuth flow
+ * - [AuthException.AccountLinkingRequiredException]: Account collision (email already exists)
+ * - [AuthException]: Other authentication errors
+ *
+ * @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 signInWithCredential
+ */
+internal suspend fun FirebaseAuthUI.signInWithProvider(
+ context: Context,
+ 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
+ signInWithCredential(
+ credential = credential,
+ displayName = authResult.user?.displayName,
+ photoUrl = authResult.user?.photoUrl,
+ )
+ }
+ updateAuthState(AuthState.Idle)
+ return
+ }
+
+ val authResult = 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
+ val user = auth.currentUser
+ val identifier = user?.email
+ if (identifier != null) {
+ SignInPreferenceManager.tryToSaveLastSignIn(
+ context = context,
+ providerId = provider.providerId,
+ identifier = identifier,
+ LOG_TAG
+ )
+ }
+
+ // 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 credential = e.updatedCredential
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ collisionException = e,
+ credential = credential
+ )
+ 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/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..741ce4f7a9
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt
@@ -0,0 +1,313 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.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 allowing for localization of the UI.
+ */
+interface AuthUIStringProvider {
+ /** Text for Email Provider */
+ val emailProvider: String
+
+ /** Button text for Google sign-in option */
+ val signInWithGoogle: String
+
+ /** Button text for Email sign-in option */
+ val signInWithEmail: String
+
+ /** Button text for Google continue option */
+ val continueWithGoogle: String
+
+ /** Button text for Email continue option */
+ val continueWithEmail: String
+
+ /** Error message when email address is invalid or empty */
+ val requiredEmailAddress: String
+
+ /** Error message when password confirmation doesn't match the original password */
+ val passwordsDoNotMatch: String
+
+ /** Weak password recovery message */
+ val weakPasswordRecoveryMessage: 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 name input field */
+ val nameHint: String
+
+ /** Label for checkbox to toggle showing the password. */
+ val showPassword: String
+
+ /** Title for reset password function. */
+ val resetPasswordAction: 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 */
+ val recoverPasswordLinkSentDialogBody: 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
+
+ /** Title text in auth method picker screen */
+ val methodPickerTitle: String
+
+ /** Description text in auth method picker screen */
+ val methodPickerDescription: String
+
+ /** Button text to sign in with email link */
+ val signInWithEmailLink: String
+
+ /** Button text to sign in with password */
+ val signInWithPassword: String
+
+ /** Message shown when prompting the user to confirm their email for cross-device flows */
+ val emailLinkPromptForEmailMessage: String
+
+ /** Message shown when email link must be opened on same device */
+ val emailLinkWrongDeviceMessage: String
+
+ /** Message shown for cross-device linking flows with the provider name */
+ fun emailLinkCrossDeviceLinkingMessage(providerName: String): String
+
+ /** Message shown when email link is invalid */
+ val emailLinkInvalidLinkMessage: String
+
+ /** Message shown when email mismatch occurs */
+ val emailMismatchMessage: String
+
+ /** No or invalid verification code entered */
+ val requiredVerificationCode: String
+
+ // Provider Picker Strings
+ /** Common button text for sign in */
+ val signInDefault: String
+
+ /** Common button text for continue */
+ val continueText: String
+
+ // General Error Messages
+ /** Required field error */
+ val requiredField: 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 sending the verification email. */
+ val sendVerificationEmailAction: String
+
+ /** Action text once the user has verified their email. */
+ val verifiedEmailAction: 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 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 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
+
+ // 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
+
+ /** Email already in use recovery message */
+ val emailAlreadyInUseRecoveryMessage: String
+
+ /** MFA required recovery message */
+ val mfaRequiredRecoveryMessage: String
+
+ /** Account linking required recovery message */
+ val accountLinkingRequiredRecoveryMessage: String
+
+ val noGoogleAccountAvailableMessage: String
+
+ /** Unknown error recovery message */
+ val unknownErrorRecoveryMessage: String
+
+ // MFA Enrollment Step Titles
+ /** Title for MFA factor selection step */
+ val mfaStepSelectFactorTitle: 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 TOTP configuration */
+ val mfaStepConfigureTotpHelper: 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 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
+
+ /** 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
+
+ /** General error message for re-authentication failures. */
+ val reauthGenericError: String
+
+ /** Privacy Policy link text */
+ val privacyPolicy: String
+
+ /** Privacy Policy message */
+ fun privacyPolicyMessage(privacyPolicyLabel: String): String
+
+ /** Tooltip message shown when new account sign-up is disabled */
+ val newAccountsDisabled: 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
new file mode 100644
index 0000000000..70f3694492
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt
@@ -0,0 +1,289 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.string_provider
+
+import android.content.Context
+import com.battlelancer.seriesguide.R
+
+class DefaultAuthUIStringProvider(
+ private val context: Context,
+) : AuthUIStringProvider {
+
+ /**
+ * Auth Provider strings
+ */
+ override val emailProvider: String
+ get() = context.getString(R.string.auth_idp_name_email)
+
+ /**
+ * Auth Provider Button Strings
+ */
+ override val signInWithGoogle: String
+ get() = context.getString(R.string.auth_action_sign_in_with_google)
+ override val signInWithEmail: String
+ get() = context.getString(R.string.auth_action_sign_in_with_email)
+
+ /**
+ * Auth Provider "Continue With" Button Strings
+ */
+ override val continueWithGoogle: String
+ get() = context.getString(R.string.auth_action_continue_with_google)
+ override val continueWithEmail: String
+ get() = context.getString(R.string.auth_action_continue_with_email)
+
+ /**
+ * Email Validator Strings
+ */
+ override val requiredEmailAddress: String
+ get() = context.getString(R.string.auth_hint_email_required)
+
+ /**
+ * Password Validator Strings
+ */
+ override val passwordsDoNotMatch: String
+ get() = context.getString(R.string.auth_hint_confirm_password_does_not_match)
+ override val weakPasswordRecoveryMessage: String
+ get() = context.getString(R.string.auth_error_weak_password)
+ override fun passwordTooShort(minimumLength: Int): String =
+ context.getString(R.string.auth_error_password_too_short, minimumLength)
+ override val passwordMissingUppercase: String
+ get() = context.getString(R.string.auth_error_password_missing_uppercase)
+ override val passwordMissingLowercase: String
+ get() = context.getString(R.string.auth_error_password_missing_lowercase)
+ override val passwordMissingDigit: String
+ get() = context.getString(R.string.auth_error_password_missing_digit)
+ override val passwordMissingSpecialCharacter: String
+ get() = context.getString(R.string.auth_error_password_missing_special_character)
+
+ /**
+ * Email Authentication Strings
+ */
+ override val signupPageTitle: String
+ get() = context.getString(R.string.auth_action_sign_up)
+ override val emailHint: String
+ get() = context.getString(R.string.auth_hint_email)
+ override val passwordHint: String
+ get() = context.getString(R.string.auth_hint_password)
+ override val confirmPasswordHint: String
+ get() = context.getString(R.string.auth_hint_confirm_password)
+ override val nameHint: String
+ get() = context.getString(R.string.auth_hint_name)
+ override val showPassword: String
+ get() = context.getString(R.string.auth_action_show_password)
+
+ override val resetPasswordAction: String
+ get() = context.getString(R.string.auth_action_reset_password)
+
+ override val sendButtonText: String
+ get() = context.getString(R.string.feedback)
+
+ override val recoverPasswordLinkSentDialogTitle: String
+ get() = context.getString(R.string.auth_title_reset_password_instructions)
+
+ override val recoverPasswordLinkSentDialogBody: String
+ get() = context.getString(R.string.auth_message_reset_password_instructions)
+
+ override val emailSignInLinkSentDialogTitle: String
+ get() = context.getString(R.string.auth_email_link_header)
+
+ override fun emailSignInLinkSentDialogBody(email: String): String =
+ context.getString(R.string.auth_email_link_email_sent, email)
+
+ override val methodPickerTitle: String
+ get() = context.getString(R.string.hexagon)
+
+ override val methodPickerDescription: String
+ get() = context.getString(R.string.hexagon_signin_choose)
+
+ override val signInWithEmailLink: String
+ get() = context.getString(R.string.auth_sign_in_with_email_link)
+
+ override val signInWithPassword: String
+ get() = context.getString(R.string.auth_sign_in_with_password)
+
+ override val emailLinkPromptForEmailMessage: String
+ get() = context.getString(R.string.auth_email_link_confirm_email_message)
+
+ override val emailLinkWrongDeviceMessage: String
+ get() = context.getString(R.string.auth_email_link_wrong_device_message)
+
+ override fun emailLinkCrossDeviceLinkingMessage(providerName: String): String =
+ context.getString(
+ R.string.auth_email_link_cross_device_linking_text,
+ providerName
+ )
+
+ override val emailLinkInvalidLinkMessage: String
+ get() = context.getString(R.string.auth_email_link_invalid_link_message)
+
+ override val emailMismatchMessage: String
+ get() = context.getString(R.string.auth_error_unknown)
+
+ override val requiredVerificationCode: String
+ get() = context.getString(R.string.auth_mfa_required_code)
+
+ /**
+ * Provider Picker Strings
+ */
+ override val signInDefault: String
+ get() = context.getString(R.string.hexagon_signin)
+ override val continueText: String
+ get() = context.getString(R.string.auth_action_continue)
+
+ /**
+ * General Error Messages
+ */
+ override val requiredField: String
+ get() = context.getString(R.string.auth_hint_required)
+
+ override val manageMfaAction: String
+ get() = context.getString(R.string.auth_manage_mfa_action)
+
+ override val signOutAction: String
+ get() = context.getString(R.string.hexagon_signout)
+
+ override fun verifyEmailInstruction(email: String): String =
+ context.getString(R.string.auth_verify_email_instruction, email)
+
+ override val sendVerificationEmailAction: String
+ get() = context.getString(R.string.auth_send_verification_email_action)
+
+ override val verifiedEmailAction: String
+ get() = context.getString(R.string.auth_verified_email_action)
+
+ override val skipAction: String
+ get() = context.getString(R.string.auth_skip_action)
+
+ override val removeAction: String
+ get() = context.getString(R.string.auth_remove_action)
+
+ override val backAction: String
+ get() = context.getString(R.string.auth_action_back)
+
+ override val verifyAction: String
+ get() = context.getString(R.string.auth_verify_action)
+
+ override val recoveryCodesSavedAction: String
+ get() = context.getString(R.string.auth_recovery_codes_saved_action)
+
+ override val secretKeyLabel: String
+ get() = context.getString(R.string.auth_secret_key_label)
+
+ override val verificationCodeLabel: String
+ get() = context.getString(R.string.auth_verification_code_label)
+
+ override val identityVerifiedMessage: String
+ get() = context.getString(R.string.auth_identity_verified_message)
+
+ override val mfaManageFactorsTitle: String
+ get() = context.getString(R.string.auth_mfa_manage_factors_title)
+
+ override val mfaManageFactorsDescription: String
+ get() = context.getString(R.string.auth_mfa_manage_factors_description)
+
+ override val mfaActiveMethodsTitle: String
+ get() = context.getString(R.string.auth_mfa_active_methods_title)
+
+ override val mfaAddNewMethodTitle: String
+ get() = context.getString(R.string.auth_mfa_add_new_method_title)
+
+ override val mfaAllMethodsEnrolledMessage: String
+ get() = context.getString(R.string.auth_mfa_all_methods_enrolled_message)
+
+ override val totpAuthenticationLabel: String
+ get() = context.getString(R.string.auth_mfa_label_totp_authentication)
+
+ override val unknownMethodLabel: String
+ get() = context.getString(R.string.auth_mfa_label_unknown_method)
+
+ override fun enrolledOnDateLabel(date: String): String =
+ context.getString(R.string.auth_mfa_enrolled_on, date)
+
+ override val setupAuthenticatorDescription: String
+ get() = context.getString(R.string.auth_mfa_setup_authenticator_description)
+
+ /**
+ * Error Recovery Dialog Strings
+ */
+ override val errorDialogTitle: String
+ get() = context.getString(R.string.auth_error_dialog_title)
+ override val retryAction: String
+ get() = context.getString(R.string.action_try_again)
+ override val dismissAction: String
+ get() = context.getString(R.string.dismiss)
+ override val networkErrorRecoveryMessage: String
+ get() = context.getString(R.string.api_error_generic, context.getString(R.string.hexagon))
+ override val invalidCredentialsRecoveryMessage: String
+ get() = context.getString(R.string.auth_error_invalid_password)
+ override val emailAlreadyInUseRecoveryMessage: String
+ get() = context.getString(R.string.auth_error_email_alreay_in_use)
+ override val mfaRequiredRecoveryMessage: String
+ get() = context.getString(R.string.auth_error_mfa_required_message)
+ override val accountLinkingRequiredRecoveryMessage: String
+ get() = context.getString(R.string.auth_error_account_linking_required)
+ override val noGoogleAccountAvailableMessage: String
+ get() = context.getString(R.string.auth_error_no_google_account_available)
+ override val unknownErrorRecoveryMessage: String
+ get() = context.getString(R.string.auth_error_unknown)
+
+ /**
+ * MFA Enrollment Step Titles
+ */
+ override val mfaStepSelectFactorTitle: String
+ get() = context.getString(R.string.auth_mfa_step_select_factor_title)
+ override val mfaStepConfigureTotpTitle: String
+ get() = context.getString(R.string.auth_mfa_step_configure_totp_title)
+ override val mfaStepVerifyFactorTitle: String
+ get() = context.getString(R.string.auth_mfa_step_verify_factor_title)
+ override val mfaStepShowRecoveryCodesTitle: String
+ get() = context.getString(R.string.auth_mfa_step_show_recovery_codes_title)
+
+ /**
+ * MFA Enrollment Helper Text
+ */
+ override val mfaStepSelectFactorHelper: String
+ get() = context.getString(R.string.auth_mfa_step_select_factor_helper)
+ override val mfaStepConfigureTotpHelper: String
+ get() = context.getString(R.string.auth_mfa_step_configure_totp_helper)
+ override val mfaStepVerifyFactorTotpHelper: String
+ get() = context.getString(R.string.auth_mfa_step_verify_factor_totp_helper)
+ override val mfaStepVerifyFactorGenericHelper: String
+ get() = context.getString(R.string.auth_mfa_step_verify_factor_generic_helper)
+ override val mfaStepShowRecoveryCodesHelper: String
+ get() = context.getString(R.string.auth_mfa_step_show_recovery_codes_helper)
+
+ // MFA Error Messages
+ override val mfaErrorRecentLoginRequired: String
+ get() = context.getString(R.string.auth_mfa_error_recent_login_required)
+ override val mfaErrorInvalidVerificationCode: String
+ get() = context.getString(R.string.auth_mfa_error_invalid_verification_code)
+ override val mfaErrorGeneric: String
+ get() = context.getString(R.string.auth_mfa_error_generic)
+
+ override val reauthDialogTitle: String
+ get() = context.getString(R.string.auth_reauth_dialog_title)
+
+ override val reauthDialogMessage: String
+ get() = context.getString(R.string.auth_reauth_dialog_message)
+
+ override fun reauthAccountLabel(email: String): String =
+ context.getString(R.string.auth_reauth_account_label, email)
+
+ override val reauthGenericError: String
+ get() = context.getString(R.string.auth_reauth_generic_error)
+
+ override val privacyPolicy: String
+ get() = context.getString(R.string.privacy_policy)
+
+ override fun privacyPolicyMessage(privacyPolicyLabel: String): String =
+ context.getString(R.string.auth_message_privacy_policy, privacyPolicyLabel)
+
+ override val newAccountsDisabled: String
+ get() = context.getString(R.string.auth_message_new_accounts_disabled)
+
+}
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..9fb49f84cb
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt
@@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.theme
+
+import androidx.annotation.DrawableRes
+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.
+ */
+ @DrawableRes
+ val icon: Int?,
+
+ /**
+ * 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.background,
+ navigationIconContentColor = MaterialTheme.colorScheme.primary,
+ titleContentColor = MaterialTheme.colorScheme.primary
+ )
+ }
+}
+
+@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..2692ded1ec
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.theme
+
+import androidx.compose.ui.graphics.Color
+import com.battlelancer.seriesguide.R
+import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Provider
+import com.battlelancer.seriesguide.backend.auth.configuration.theme.ProviderStyleDefaults.Google
+
+/**
+ * Default provider styling configurations for authentication providers.
+ *
+ * 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]) and then modified using the [AuthUITheme.ProviderStyle.copy] method.
+ */
+object ProviderStyleDefaults {
+ val Google = AuthUITheme.ProviderStyle(
+ icon = R.drawable.ic_account_circle_on_surface_light_24dp,
+ backgroundColor = Color.White,
+ contentColor = Color(0xFF49454E /* light colorOnSurfaceVariant */ )
+ )
+
+ val Email = AuthUITheme.ProviderStyle(
+ icon = R.drawable.ic_email_white_24dp,
+ backgroundColor = Color(0xFFD0021B),
+ contentColor = Color.White
+ )
+
+ val default: Map
+ get() = mapOf(
+ Provider.GOOGLE.id to Google,
+ Provider.EMAIL.id to Email,
+ )
+}
\ 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..f326903422
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.validators
+
+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)
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty() ||
+ !android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.requiredEmailAddress
+ )
+ 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..95870280e6
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt
@@ -0,0 +1,16 @@
+// 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
+
+/**
+ * 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..9093cf2bac
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt
@@ -0,0 +1,31 @@
+// 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
+
+/**
+ * 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..3f67964724
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt
@@ -0,0 +1,44 @@
+// 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
+
+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..7809d38f96
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt
@@ -0,0 +1,48 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.validators
+
+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,
+ 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 there are no rules (such as when signing in) at least verify the password is not empty
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.requiredField
+ )
+ 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/VerificationCodeValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt
new file mode 100644
index 0000000000..f1a2bd2a44
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.validators
+
+import com.battlelancer.seriesguide.backend.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 {
+ val isInvalid = if (value.isEmpty()) {
+ true
+ } else {
+ val digitsOnly = value.replace(Regex("[^0-9]"), "")
+ digitsOnly.length != 6
+ }
+
+ return if (isInvalid) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.requiredVerificationCode
+ )
+ false
+ } else {
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ 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..22721549de
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt
@@ -0,0 +1,18 @@
+// 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.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..643365b162
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt
@@ -0,0 +1,192 @@
+// 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.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.battlelancer.seriesguide.backend.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/mfa/MfaChallengeContentState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt
new file mode 100644
index 0000000000..f7e9722fce
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt
@@ -0,0 +1,94 @@
+// 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 com.battlelancer.seriesguide.backend.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 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 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,
+
+ /** `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 = {},
+
+ /** 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()
+
+}
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..a9ca1ef1cf
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt
@@ -0,0 +1,142 @@
+// 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 com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor
+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.battlelancer.seriesguide.backend.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.battlelancer.seriesguide.backend.auth.configuration.MfaConfiguration.requireEnrollment].
+ *
+ * @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 recoveryCodes (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) A list of one-time backup codes the user should save. Only present if [com.battlelancer.seriesguide.backend.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,
+
+ // 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,
+
+ // 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.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..b639a53889
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt
@@ -0,0 +1,81 @@
+// 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 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.
+ *
+ * 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.battlelancer.seriesguide.backend.auth.configuration.MfaConfiguration].
+ */
+ SelectFactor,
+
+ /**
+ * 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.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.ConfigureTotp -> stringProvider.mfaStepConfigureTotpHelper
+ MfaEnrollmentStep.VerifyFactor -> when (selectedFactor) {
+ 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..5838e4d3de
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt
@@ -0,0 +1,31 @@
+// 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 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
+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.networkErrorRecoveryMessage
+ else -> stringProvider.mfaErrorGeneric
+ }
+}
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..d0fd358d7d
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt
@@ -0,0 +1,143 @@
+// 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 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..5e09e3651c
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt
@@ -0,0 +1,96 @@
+// 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.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/AuthHorizontalDivider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthHorizontalDivider.kt
new file mode 100644
index 0000000000..1201dd5e3c
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthHorizontalDivider.kt
@@ -0,0 +1,19 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann
+
+package com.battlelancer.seriesguide.backend.auth.ui.components
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun AuthHorizontalDivider() {
+ Spacer(modifier = Modifier.height(24.dp))
+ HorizontalDivider(modifier = Modifier.fillMaxWidth())
+ Spacer(modifier = Modifier.height(24.dp))
+}
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..ad0d3d9996
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt
@@ -0,0 +1,244 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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 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.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.Shape
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+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.AuthUIStringProvider
+import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.DefaultAuthUIStringProvider
+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.
+ *
+ * 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 authTheme = LocalAuthUITheme.current
+ val providerLabel =
+ label ?: resolveProviderLabel(provider, stringProvider, 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 = painterResource(providerIcon),
+ contentDescription = providerLabel,
+ tint = iconTint
+ )
+ } else {
+ Image(
+ modifier = Modifier
+ .size(24.dp),
+ painter = painterResource(providerIcon),
+ contentDescription = providerLabel
+ )
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ }
+
+ if (subtitle != null) {
+ Column(
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(text = providerLabel)
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ } else {
+ Text(text = providerLabel)
+ }
+ }
+ }
+}
+
+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,
+ showAsContinue: Boolean = false,
+): String = when (provider) {
+ is AuthProvider.GenericOAuth -> provider.buttonLabel
+ else -> when (Provider.fromId(provider.providerId)) {
+ Provider.GOOGLE -> if (showAsContinue) stringProvider.continueWithGoogle else stringProvider.signInWithGoogle
+ Provider.EMAIL -> if (showAsContinue) stringProvider.continueWithEmail else stringProvider.signInWithEmail
+ 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
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Google(
+ serverClientId = "EXAMPLE"
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.GenericOAuth(
+ providerName = "Generic Provider",
+ providerId = "unknown_provider",
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ buttonLabel = "Unsupported Provider with a super duper long message that wraps",
+ buttonIcon = R.drawable.ic_account_circle_on_surface_light_24dp,
+ buttonColor = null,
+ contentColor = null,
+ ),
+ subtitle = "Unsupported Provider with a super duper long message that wraps",
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ }
+}
diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthShowPasswordToggle.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthShowPasswordToggle.kt
new file mode 100644
index 0000000000..795d433c22
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthShowPasswordToggle.kt
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann
+
+package com.battlelancer.seriesguide.backend.auth.ui.components
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.minimumInteractiveComponentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider
+
+@Composable
+fun AuthShowPasswordToggle(
+ value: Boolean,
+ onValueChange: (Boolean) -> Unit
+) {
+ val stringProvider = LocalAuthUIStringProvider.current
+ Row(
+ modifier = Modifier.toggleable(
+ value = value,
+ role = Role.Checkbox,
+ onValueChange = onValueChange
+ ),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ modifier = Modifier.minimumInteractiveComponentSize(),
+ checked = value,
+ onCheckedChange = null
+ )
+ Text(
+ modifier = Modifier.padding(end = 16.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ text = stringProvider.showPassword,
+ )
+ }
+}
\ No newline at end of file
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..8e31606f9f
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt
@@ -0,0 +1,201 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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 androidx.annotation.DrawableRes
+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.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+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.res.painterResource
+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.text.input.VisualTransformation
+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.string_provider.DefaultAuthUIStringProvider
+import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.battlelancer.seriesguide.backend.auth.configuration.validators.EmailValidator
+import com.battlelancer.seriesguide.backend.auth.configuration.validators.FieldValidator
+
+/**
+ * A customizable input field with built-in validation display.
+ */
+@Composable
+fun AuthTextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: @Composable (() -> Unit)? = null,
+ isSecureTextField: Boolean = false,
+ textVisible: 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,
+ @DrawableRes leadingIcon: Int? = null
+) {
+ // 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 && !textVisible)
+ PasswordVisualTransformation() else visualTransformation,
+ leadingIcon = leadingIcon?.let {
+ {
+ Icon(
+ painter = painterResource(leadingIcon),
+ contentDescription = null /* TextField has label */
+ )
+ }
+ }
+ )
+}
+
+@Composable
+fun AuthEmailTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ enabled: Boolean = true,
+ validator: FieldValidator? = null
+) {
+ val stringProvider = LocalAuthUIStringProvider.current
+ AuthTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = {
+ Text(stringProvider.emailHint)
+ },
+ enabled = enabled,
+ validator = validator,
+ leadingIcon = R.drawable.ic_email_control_24dp
+ )
+}
+
+@Composable
+fun AuthPasswordTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: @Composable (() -> Unit)? = null,
+ enabled: Boolean = true,
+ textVisible: Boolean,
+ validator: FieldValidator? = null
+) {
+ val stringProvider = LocalAuthUIStringProvider.current
+ AuthTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = label ?: {
+ Text(stringProvider.passwordHint)
+ },
+ enabled = enabled,
+ validator = validator,
+ isSecureTextField = true,
+ textVisible = textVisible,
+ leadingIcon = R.drawable.ic_rounded_password_control_24dp
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+internal fun PreviewAuthTextField() {
+ val context = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(context)
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ AuthTextField(
+ value = "Base variant",
+ label = {
+ Text("Base variant")
+ },
+ onValueChange = { _ ->
+ },
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthEmailTextField(
+ value = "AuthEmailTextField",
+ onValueChange = { _ ->
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthPasswordTextField(
+ value = "example value",
+ textVisible = false,
+ onValueChange = { _ ->
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthPasswordTextField(
+ value = "example value",
+ textVisible = true,
+ onValueChange = { _ ->
+ }
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTopAppBar.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTopAppBar.kt
new file mode 100644
index 0000000000..d2e16e507f
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTopAppBar.kt
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann
+
+package com.battlelancer.seriesguide.backend.auth.ui.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+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.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+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
+
+@Composable
+fun AuthTopAppBar(
+ title: String,
+ onNavigateBack: (() -> Unit)?,
+) {
+ val stringProvider = LocalAuthUIStringProvider.current
+
+ TopAppBar(
+ title = {
+ Text(title)
+ },
+ navigationIcon = {
+ if (onNavigateBack != null) {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ painter = painterResource(com.battlelancer.seriesguide.R.drawable.ic_arrow_back_control_24dp),
+ contentDescription = stringProvider.backAction
+ )
+ }
+ }
+ },
+ colors = AuthUITheme.topAppBarColors
+ )
+}
+
+@Preview
+@Composable
+fun PreviewAuthTopAppBar() {
+ val applicationContext = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
+ ) {
+ Scaffold(
+ topBar = {
+ AuthTopAppBar(
+ title = "This Is A Title",
+ onNavigateBack = {}
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ ) { }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/BoxWithCenteredColumn.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/BoxWithCenteredColumn.kt
new file mode 100644
index 0000000000..241e990512
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/BoxWithCenteredColumn.kt
@@ -0,0 +1,48 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann
+
+package com.battlelancer.seriesguide.backend.auth.ui.components
+
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun BoxWithCenteredColumn(
+ insetPadding: PaddingValues,
+ content: @Composable (ColumnScope.() -> Unit)
+) {
+ BoxWithConstraints(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ // If wider than 400 dp center align, use padding so whole screen remains scrollable
+ val maxContentWidth = 400.dp
+ val defaultContentPadding = 16.dp
+ val contentCenteredPadding =
+ if (maxWidth > defaultContentPadding + maxContentWidth + defaultContentPadding) {
+ val horizontalPadding = (maxWidth - maxContentWidth) / 2
+ PaddingValues(
+ horizontal = horizontalPadding,
+ vertical = defaultContentPadding
+ )
+ } else {
+ PaddingValues(defaultContentPadding)
+ }
+
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(insetPadding)
+ .padding(contentCenteredPadding),
+ content = content
+ )
+ }
+}
\ No newline at end of file
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..552d89b79c
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt
@@ -0,0 +1,193 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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 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.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.
+ *
+ * This dialog provides friendly error messages and actionable recovery suggestions
+ * based on the specific [AuthException] type. It integrates with [AuthUIStringProvider]
+ * for localization support.
+ *
+ * @param error The [AuthException] to display recovery information for. Should never be an
+ * [AuthException.AuthCancelledException].
+ * @param stringProvider The [AuthUIStringProvider] for localized strings
+ * @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
+ */
+@Composable
+fun ErrorRecoveryDialog(
+ error: AuthException,
+ stringProvider: AuthUIStringProvider,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ onRecover: ((AuthException) -> Unit),
+ 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)
+ }
+ ) {
+ 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 ->
+ stringProvider.invalidCredentialsRecoveryMessage
+
+ is AuthException.WeakPasswordException -> {
+ // Include specific reason if available
+ val baseMessage = stringProvider.weakPasswordRecoveryMessage
+ error.reason?.let { reason ->
+ "$baseMessage\n\n$reason"
+ } ?: baseMessage
+ }
+
+ is AuthException.EmailAlreadyInUseException -> stringProvider.emailAlreadyInUseRecoveryMessage
+
+ is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage
+ is AuthException.AccountLinkingRequiredException ->
+ stringProvider.accountLinkingRequiredRecoveryMessage
+
+ is AuthException.NoGoogleAccountAvailableException ->
+ stringProvider.noGoogleAccountAvailableMessage
+
+ is AuthException.EmailMismatchException -> stringProvider.emailMismatchMessage
+ is AuthException.InvalidEmailLinkException -> stringProvider.emailLinkInvalidLinkMessage
+ is AuthException.EmailLinkWrongDeviceException -> stringProvider.emailLinkWrongDeviceMessage
+
+ is AuthException.EmailLinkPromptForEmailException -> stringProvider.emailLinkPromptForEmailMessage
+ is AuthException.EmailLinkCrossDeviceLinkingException -> {
+ val providerName = error.providerName ?: stringProvider.emailProvider
+ stringProvider.emailLinkCrossDeviceLinkingMessage(providerName)
+ }
+
+ is AuthException.AdminRestrictedException -> {
+ // AdminRestrictedException currently only by SignUpUI, deletion is handled by
+ // RemoveCloudAccountDialogFragment. So this should only occur when trying to create a
+ // new account.
+ stringProvider.newAccountsDisabled
+ }
+
+ 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.EmailAlreadyInUseException, // onRecover switches to sign-in mode
+ is AuthException.AccountLinkingRequiredException // onRecover navigates to email auth screen
+ -> stringProvider.signInDefault
+
+ is AuthException.MfaRequiredException, // Dialog is just dismissed
+ is AuthException.EmailLinkPromptForEmailException, // onRecover navigates to email auth screen
+ is AuthException.EmailLinkCrossDeviceLinkingException, // onRecover navigates to email auth screen
+ is AuthException.EmailLinkWrongDeviceException // Dialog is just dismissed
+ -> stringProvider.continueText
+
+ else -> stringProvider.retryAction
+ }
+}
+
+/**
+ * If for the given [AuthException] the recover action should be shown.
+ * If so, may also want to supply a custom [getRecoveryActionText].
+ *
+ * @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,
+ is AuthException.EmailAlreadyInUseException,
+ is AuthException.AccountLinkingRequiredException,
+ is AuthException.MfaRequiredException,
+ is AuthException.EmailLinkPromptForEmailException,
+ is AuthException.EmailLinkCrossDeviceLinkingException,
+ is AuthException.EmailLinkWrongDeviceException,
+ is AuthException.UnknownException
+ -> true
+
+ else -> false
+ }
+}
diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/LoadingDialog.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/LoadingDialog.kt
new file mode 100644
index 0000000000..dacd53eac0
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/LoadingDialog.kt
@@ -0,0 +1,49 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann
+
+package com.battlelancer.seriesguide.backend.auth.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Surface
+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.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun LoadingDialog() {
+ AlertDialog(
+ onDismissRequest = {},
+ confirmButton = {},
+ containerColor = Color.Transparent,
+ text = {
+ Column(
+ modifier = Modifier
+ .padding(24.dp)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ )
+}
+
+@Preview
+@Composable
+fun LoadingDialogPreview() {
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ LoadingDialog()
+ }
+}
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..0b93d6e26c
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt
@@ -0,0 +1,122 @@
+// 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 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..fff02a10f1
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt
@@ -0,0 +1,201 @@
+// 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.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.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.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
+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.invalidCredentialsRecoveryMessage
+ message?.contains("network", ignoreCase = true) == true ->
+ stringProvider.networkErrorRecoveryMessage
+ else -> stringProvider.reauthGenericError
+}
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..0927468dd9
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt
@@ -0,0 +1,392 @@
+// 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.animation.core.animateDpAsState
+import androidx.compose.animation.core.tween
+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.wrapContentSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+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.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.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.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
+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.core.text.isDigitsOnly
+import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme
+import com.battlelancer.seriesguide.backend.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..186af1b609
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.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.TextDecoration
+import androidx.compose.ui.text.withLink
+import androidx.core.net.toUri
+
+@Composable
+internal fun AnnotatedStringResource(
+ context: Context,
+ modifier: Modifier = Modifier,
+ template: String,
+ vararg links: Pair
+) {
+ 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
+ )
+}
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..a3c79ec6b2
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt
@@ -0,0 +1,219 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.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.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+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.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.ui.components.AuthHorizontalDivider
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthProviderButton
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTopAppBar
+import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager.SignInPreference
+import com.battlelancer.seriesguide.util.ThemeUtils.plus
+
+/**
+ * Renders the provider selection screen.
+ *
+ * @param providers The list of providers to display.
+ * @param logo An optional logo to display.
+ * @param onNavigateBack When the back navigation icon was selected.
+ * @param onProviderSelected A callback when a provider is selected.
+ * @param privacyPolicyUrl The URL for the Privacy Policy.
+ * @param lastSignInPreference The last sign-in preference to show a "Continue as..." button.
+ */
+@Composable
+fun AuthMethodPicker(
+ providers: List,
+ logo: Int? = null,
+ onNavigateBack: () -> Unit,
+ onProviderSelected: (AuthProvider, SignInPreference?) -> Unit,
+ privacyPolicyUrl: String? = null,
+ lastSignInPreference: SignInPreference? = null,
+) {
+ val context = LocalContext.current
+ val stringProvider = LocalAuthUIStringProvider.current
+
+ Scaffold(
+ topBar = {
+ AuthTopAppBar(
+ title = stringProvider.methodPickerTitle,
+ onNavigateBack = onNavigateBack
+ )
+ }
+ ) { contentPadding ->
+ BoxWithConstraints(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ // If wider than 300 dp center align, use padding so whole screen remains scrollable
+ val maxContentWidth = 300.dp
+ val defaultContentPadding = 16.dp
+ val contentCenteredPadding =
+ if (maxWidth > defaultContentPadding + maxContentWidth + defaultContentPadding) {
+ val horizontalPadding = (maxWidth - maxContentWidth) / 2
+ PaddingValues(horizontal = horizontalPadding, vertical = defaultContentPadding)
+ } else {
+ PaddingValues(defaultContentPadding)
+ }
+
+ LazyColumn(
+ contentPadding = contentPadding + contentCenteredPadding
+ ) {
+ item {
+ Column(
+ modifier = Modifier
+ .padding(top = 8.dp, bottom = 24.dp)
+ .fillMaxWidth()
+ ) {
+ Image(
+ modifier = Modifier
+ .size(48.dp)
+ .align(Alignment.CenterHorizontally),
+ painter = painterResource(
+ logo ?: R.drawable.ic_account_circle_control_24dp
+ ),
+ contentDescription = null /* Title is below */
+ )
+ Text(
+ text = stringProvider.methodPickerDescription,
+ modifier = Modifier.padding(top = 16.dp),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ AnnotatedStringResource(
+ modifier = Modifier.padding(top = 16.dp),
+ context = context,
+ template = stringProvider.privacyPolicyMessage(
+ privacyPolicyLabel = stringProvider.privacyPolicy
+ ),
+ links = arrayOf(
+ stringProvider.privacyPolicy to (privacyPolicyUrl ?: "")
+ )
+ )
+ }
+ }
+
+ // Show "Continue with..." 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, preference) }
+ )
+
+ AuthHorizontalDivider()
+ }
+ }
+ }
+
+ // Show all providers
+ itemsIndexed(providers) { _, provider ->
+ Box(
+ modifier = Modifier
+ .padding(bottom = 16.dp)
+ ) {
+ AuthProviderButton(
+ modifier = Modifier
+ .fillMaxWidth(),
+ onClick = {
+ onProviderSelected(provider, null)
+ },
+ provider = provider,
+ stringProvider = LocalAuthUIStringProvider.current
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * 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() {
+ val applicationContext = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
+ ) {
+ AuthMethodPicker(
+ providers = listOf(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null
+ ),
+ AuthProvider.Google(
+ serverClientId = "EXAMPLE"
+ )
+ ),
+ onNavigateBack = {},
+ onProviderSelected = { _, _ -> },
+ privacyPolicyUrl = "https://app.example/privacy",
+ lastSignInPreference = SignInPreference(
+ providerId = Provider.EMAIL.id,
+ identifier = "someone@domain.example",
+ timestamp = 0
+ )
+ )
+ }
+ }
+}
\ 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..bc2770a5e3
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt
@@ -0,0 +1,534 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.screens
+
+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.fillMaxSize
+import androidx.compose.material3.Surface
+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.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+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.MfaConfiguration
+import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider
+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.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.ui.components.ErrorRecoveryDialog
+import com.battlelancer.seriesguide.backend.auth.ui.components.LoadingDialog
+import com.battlelancer.seriesguide.backend.auth.ui.method_picker.AuthMethodPicker
+import com.battlelancer.seriesguide.backend.auth.ui.screens.email.EmailAuthMode
+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
+import com.google.firebase.auth.AuthCredential
+import com.google.firebase.auth.MultiFactorResolver
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+/**
+ * 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.
+ *
+ * [onSignInSuccess] is called on successful sign-in and if the user is different from before.
+ *
+ * [onSignInFailure] is called when
+ * - a selected provider is not supported by this screen
+ * - email auth has failed
+ * - signing out (from the success screen) has failed
+ * - MFA enrollment failed
+ * - MFA challenge failed
+ *
+ * [onSignInCancelled] is called if state reaches [AuthState.Cancelled].
+ *
+ * If [emailLink] is given and there is a matching email address stored, upon launch tries to sign
+ * in using the email link.
+ *
+ * Supply [mfaConfiguration] to customize settings for [MfaEnrollmentScreen].
+ */
+@Composable
+fun FirebaseAuthScreen(
+ configuration: AuthUIConfiguration,
+ onSignInSuccess: () -> Unit,
+ onSignInFailure: (AuthException) -> Unit,
+ onSignInCancelled: () -> Unit,
+ authUI: FirebaseAuthUI = FirebaseAuthUI.getInstance(),
+ emailLink: String? = null,
+ mfaConfiguration: MfaConfiguration = MfaConfiguration()
+) {
+ val activity = LocalActivity.current
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ val stringProvider: AuthUIStringProvider = DefaultAuthUIStringProvider(context)
+ val navController = rememberNavController()
+
+ val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
+ val isErrorDialogVisible = remember { mutableStateOf(false) }
+ val lastSuccessfulUserId = remember { mutableStateOf(null) }
+ val pendingLinkingCredential = remember { mutableStateOf(null) }
+ val pendingResolver = remember { mutableStateOf(null) }
+ // The email screen mode state is remembered here instead of inside EmailAuthScreen as error
+ // recovery might have to change it.
+ val emailScreenMode = rememberSaveable { mutableStateOf(null) }
+ val emailLinkFromDifferentDevice = remember { mutableStateOf(null) }
+ val lastSignInPreference =
+ remember { mutableStateOf(null) }
+ val signInPreference =
+ remember { mutableStateOf(null) }
+
+ // Load last sign-in preference on launch
+ LaunchedEffect(authState) {
+ lastSignInPreference.value = SignInPreferenceManager.getLastSignIn(context)
+ }
+
+ val googleProvider =
+ configuration.providers.filterIsInstance().firstOrNull()
+ val emailProvider = configuration.providers.filterIsInstance().firstOrNull()
+ val genericOAuthProviders =
+ configuration.providers.filterIsInstance()
+
+ val logoAsset = configuration.logo
+
+ val onSignInWithGoogle = googleProvider?.let {
+ authUI.rememberGoogleSignInHandler(
+ context = context,
+ provider = it
+ )
+ }
+
+ val genericOAuthHandlers = genericOAuthProviders.associateWith {
+ authUI.rememberOAuthSignInHandler(
+ context = context,
+ activity = activity,
+ config = configuration,
+ provider = it
+ )
+ }
+
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides configuration.stringProvider
+ ) {
+ 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) {
+ AuthMethodPicker(
+ providers = configuration.providers,
+ logo = logoAsset,
+ privacyPolicyUrl = configuration.privacyPolicyUrl,
+ lastSignInPreference = lastSignInPreference.value,
+ onNavigateBack = {
+ authUI.updateAuthState(AuthState.Cancelled)
+ },
+ onProviderSelected = { provider, signInPref ->
+ when (provider) {
+ is AuthProvider.Email -> {
+ signInPreference.value = signInPref
+ // Reset the mode, in case the user leaves the email screen
+ // and then returns to it again
+ emailScreenMode.value = EmailAuthMode.SignIn
+ navController.navigate(AuthRoute.Email.route)
+ }
+
+ is AuthProvider.Google -> onSignInWithGoogle?.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,
+ mode = emailScreenMode.value,
+ changeMode = { mode ->
+ emailScreenMode.value = mode
+ },
+ signInPreference = signInPreference.value,
+ 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.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))
+ }
+ },
+ onSendVerification = {
+ authUI.getCurrentUser()?.sendEmailVerification()
+ },
+ 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
+ val email = user.email
+ if (user.isEmailVerified) {
+ authUI.updateAuthState(
+ AuthState.Success(user = user)
+ )
+ } else if (email != null) {
+ // Email still not verified, keep showing verification screen
+ authUI.updateAuthState(
+ AuthState.RequiresEmailVerification(
+ user = user,
+ email = email
+ )
+ )
+ }
+ // Otherwise, if the email was removed from the user for
+ // whatever reason stay on the verification screen
+ // (== do not update auth state) to at least allow to
+ // sign out.
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to refresh user")
+ }
+ }
+ },
+ onNavigate = { route ->
+ navController.navigate(route.route)
+ }
+ )
+ }
+
+ SuccessDestination(
+ authState = authState,
+ uiContext = uiContext
+ )
+ }
+
+ composable(AuthRoute.MfaEnrollment.route) {
+ val user = authUI.getCurrentUser()
+ if (user != null) {
+ MfaEnrollmentScreen(
+ user = user,
+ auth = authUI.auth,
+ configuration = mfaConfiguration,
+ 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,
+ 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,
+ provider = emailProvider,
+ email = "", // Empty email triggers cross-device detection
+ emailLink = emailLink
+ )
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to complete email link sign-in")
+ }
+ }
+ }
+
+ // 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
+
+ if (state.user.uid != lastSuccessfulUserId.value) {
+ // Set before callback in case callback changes state
+ lastSuccessfulUserId.value = state.user.uid
+ onSignInSuccess()
+
+ // 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 -> {
+ pendingResolver.value = null
+ pendingLinkingCredential.value = null
+ // Navigate to success screen (handles email verification)
+ 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 -> {
+ // Clear any state from operations
+ pendingResolver.value = null
+ pendingLinkingCredential.value = null
+ lastSuccessfulUserId.value = null
+ // If not already shown, navigate to method picker
+ if (currentRoute != AuthRoute.MethodPicker.route) {
+ navController.navigate(AuthRoute.MethodPicker.route) {
+ popUpTo(AuthRoute.MethodPicker.route) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ onSignInCancelled()
+ }
+
+ else -> Unit
+ }
+ }
+
+ // Handle errors
+ val errorState = authState as? AuthState.Error
+ if (errorState != null) {
+ val exception = when (val throwable = errorState.exception) {
+ is AuthException -> throwable
+ else -> AuthException.from(throwable)
+ }
+
+ // The launch state is only run again if the error changes (or if auth state changed
+ // to a non-error state before, and on config changes).
+ LaunchedEffect(errorState) {
+ // Don't show error dialog if the user has canceled an operation (like a
+ // credentials manager popup).
+ if (exception !is AuthException.AuthCancelledException) {
+ isErrorDialogVisible.value = true
+ }
+ }
+
+ // Error dialog
+ if (isErrorDialogVisible.value) {
+ ErrorRecoveryDialog(
+ error = exception,
+ stringProvider = stringProvider,
+ onRecover = { exception ->
+ isErrorDialogVisible.value = false
+ // Clear error
+ authUI.updateAuthState(AuthState.Idle)
+ // If applicable, do recovery action
+ when (exception) {
+ is AuthException.EmailAlreadyInUseException -> {
+ // Switch email screen to sign-in mode
+ emailScreenMode.value = EmailAuthMode.SignIn
+ }
+
+ 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 = {
+ isErrorDialogVisible.value = false
+ // Clear error
+ authUI.updateAuthState(AuthState.Idle)
+ }
+ )
+ }
+ }
+
+ val loadingState = authState as? AuthState.Loading
+ if (loadingState != null) {
+ LoadingDialog()
+ }
+ }
+ }
+}
+
+sealed class AuthRoute(val route: String) {
+ object MethodPicker : AuthRoute("auth_method_picker")
+ object Email : AuthRoute("auth_email")
+ 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/MfaChallengeDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt
new file mode 100644
index 0000000000..e2a9b49bdb
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt
@@ -0,0 +1,103 @@
+// 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
+
+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.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) {
+ 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 = stringProvider.mfaStepVerifyFactorTitle,
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center
+ )
+
+ 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))
+
+ 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..2636205384
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt
@@ -0,0 +1,129 @@
+// 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
+
+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.battlelancer.seriesguide.backend.auth.configuration.MfaFactor
+import com.battlelancer.seriesguide.backend.auth.mfa.MfaChallengeContentState
+import com.google.firebase.auth.AuthResult
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.MultiFactorResolver
+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
+
+/**
+ * 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 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) {
+ TotpMultiFactorGenerator.FACTOR_ID -> MfaFactor.Totp
+ else -> MfaFactor.Totp
+ }
+ }
+
+ val state = MfaChallengeContentState(
+ factorType = factorType,
+ 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.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
+ }
+ }
+ },
+ 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..5e4829579f
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt
@@ -0,0 +1,595 @@
+// 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
+
+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.ExperimentalMaterial3Api
+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.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.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.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.MultiFactorInfo
+import com.google.firebase.auth.TotpMultiFactorInfo
+
+@Composable
+internal fun DefaultMfaEnrollmentContent(
+ state: MfaEnrollmentContentState,
+ 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.invalidCredentialsRecoveryMessage
+
+ exception.message?.contains("network", ignoreCase = true) == true ->
+ stringProvider.networkErrorRecoveryMessage
+
+ 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.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.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 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()
+ .verticalScroll(rememberScrollState())
+ .padding(innerPadding)
+ .padding(16.dp),
+ 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.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 TotpMultiFactorInfo -> stringProvider.totpAuthenticationLabel
+ else -> stringProvider.unknownMethodLabel
+ },
+ style = MaterialTheme.typography.titleSmall
+ )
+ Text(
+ text = when (factorInfo) {
+ 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()
+ .verticalScroll(rememberScrollState())
+ .padding(innerPadding)
+ .padding(16.dp),
+ 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()
+ .verticalScroll(rememberScrollState())
+ .padding(innerPadding)
+ .padding(16.dp),
+ 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()
+ .verticalScroll(rememberScrollState())
+ .padding(innerPadding)
+ .padding(16.dp),
+ 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..fc1a89a353
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt
@@ -0,0 +1,281 @@
+// 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
+
+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.battlelancer.seriesguide.backend.auth.configuration.MfaConfiguration
+import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor
+import com.battlelancer.seriesguide.backend.auth.mfa.MfaEnrollmentContentState
+import com.battlelancer.seriesguide.backend.auth.mfa.MfaEnrollmentStep
+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
+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,
+ onComplete: () -> Unit,
+ onSkip: () -> Unit = {},
+ onError: (Exception) -> Unit = {},
+ content: @Composable ((MfaEnrollmentContentState) -> Unit)? = null
+) {
+ val coroutineScope = rememberCoroutineScope()
+
+ 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 totpSecret = remember { mutableStateOf(null) }
+ val totpQrCodeUrl = remember { mutableStateOf(null) }
+
+ val verificationCode = rememberSaveable { mutableStateOf("") }
+
+ val recoveryCodes = remember { mutableStateOf?>(null) }
+
+ val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) }
+
+ // 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.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.ConfigureTotp -> {
+ currentStep.value = MfaEnrollmentStep.SelectFactor
+ selectedFactor.value = null
+ totpSecret.value = null
+ totpQrCodeUrl.value = null
+ }
+ MfaEnrollmentStep.VerifyFactor -> {
+ verificationCode.value = ""
+ when (selectedFactor.value) {
+ 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.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,
+ 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.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,
+ recoveryCodes = recoveryCodes.value,
+ onCodesSavedClick = {
+ onComplete()
+ }
+ )
+
+ if (content != null) {
+ content(state)
+ } else {
+ DefaultMfaEnrollmentContent(
+ state = state,
+ 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/SuccessDestination.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/SuccessDestination.kt
new file mode 100644
index 0000000000..e8d378fb3b
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/SuccessDestination.kt
@@ -0,0 +1,220 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved.
+// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann
+
+package com.battlelancer.seriesguide.backend.auth.ui.screens
+
+import androidx.compose.foundation.Image
+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.size
+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.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.battlelancer.seriesguide.R
+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.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.AuthUITheme
+
+data class AuthSuccessUiContext(
+ val authUI: FirebaseAuthUI,
+ val stringProvider: AuthUIStringProvider,
+ val configuration: AuthUIConfiguration,
+ val onSignOut: () -> Unit,
+ val onManageMfa: () -> Unit,
+ val onSendVerification: () -> Unit,
+ /**
+ * Callback to reload the signed-in user to check if email is now verified.
+ * Should update auth state to either [AuthState.Success] or
+ * [AuthState.RequiresEmailVerification].
+ */
+ val onReloadUser: () -> Unit,
+ val onNavigate: (AuthRoute) -> Unit,
+)
+
+/**
+ * On [AuthState.Success] displays [AuthSuccessContent] with details, MFA management link and sign
+ * out action for the contained user.
+ *
+ * On [AuthState.RequiresEmailVerification] displays [EmailVerificationContent] with actions to
+ * verify the contained email address.
+ *
+ * Otherwise, displays a progress indicator.
+ */
+@Composable
+fun SuccessDestination(
+ authState: AuthState,
+ uiContext: AuthSuccessUiContext,
+) {
+ when (authState) {
+ is AuthState.Success -> {
+ val userIdentifier =
+ authState.user.email ?: authState.user.phoneNumber ?: authState.user.uid
+ AuthSuccessContent(
+ userIdentifier = userIdentifier,
+ stringProvider = uiContext.stringProvider,
+ showManageMfaAction = uiContext.configuration.isMfaEnabled,
+ onManageMfa = uiContext.onManageMfa,
+ onSignOut = uiContext.onSignOut
+ )
+ }
+
+ is AuthState.RequiresEmailVerification -> {
+ EmailVerificationContent(
+ stringProvider = uiContext.stringProvider,
+ onCheckVerificationStatus = uiContext.onReloadUser,
+ onSendVerification = uiContext.onSendVerification,
+ onSignOut = uiContext.onSignOut,
+ email = authState.email
+ )
+ }
+
+ else -> {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+}
+
+@Composable
+private fun AuthSuccessContent(
+ userIdentifier: String,
+ stringProvider: AuthUIStringProvider,
+ showManageMfaAction: Boolean,
+ onManageMfa: () -> Unit,
+ onSignOut: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Image(
+ modifier = Modifier
+ .size(48.dp)
+ .align(Alignment.CenterHorizontally),
+ painter = painterResource(R.drawable.ic_account_circle_control_24dp),
+ contentDescription = null /* Title is below */
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ if (userIdentifier.isNotBlank()) {
+ Text(
+ text = userIdentifier,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ if (showManageMfaAction) {
+ Button(onClick = onManageMfa) {
+ Text(stringProvider.manageMfaAction)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ Button(onClick = onSignOut) {
+ Text(stringProvider.signOutAction)
+ }
+ }
+}
+
+@Composable
+private fun EmailVerificationContent(
+ email: String,
+ stringProvider: AuthUIStringProvider,
+ onSendVerification: () -> Unit,
+ onCheckVerificationStatus: () -> Unit,
+ onSignOut: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringProvider.verifyEmailInstruction(email),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = onSendVerification) {
+ Text(stringProvider.sendVerificationEmailAction)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = onCheckVerificationStatus) {
+ Text(stringProvider.verifiedEmailAction)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = onSignOut) {
+ Text(stringProvider.signOutAction)
+ }
+ }
+}
+
+@Preview
+@Composable
+fun AuthSuccessContentPreview() {
+ val applicationContext = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
+ ) {
+ AuthSuccessContent(
+ userIdentifier = "user@app.example",
+ showManageMfaAction = true,
+ stringProvider = stringProvider,
+ onSignOut = { },
+ onManageMfa = { }
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun EmailVerificationContentPreview() {
+ val applicationContext = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
+ ) {
+ EmailVerificationContent(
+ email = "user@app.example",
+ stringProvider = stringProvider,
+ onSendVerification = { },
+ onCheckVerificationStatus = { },
+ onSignOut = { }
+ )
+ }
+ }
+}
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..7e71687daf
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt
@@ -0,0 +1,376 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.screens.email
+
+import android.content.Context
+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.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.createUserWithEmailAndPassword
+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.util.SignInPreferenceManager
+import com.google.firebase.auth.AuthCredential
+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 onResetLinkSentConfirmed: () -> Unit,
+ 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 signInPreference A sign-in preference to pre-populate the email address with
+ * @param onSuccess
+ * @param onError
+ * @param onCancel
+ * @param content
+ */
+@Composable
+fun EmailAuthScreen(
+ context: Context,
+ configuration: AuthUIConfiguration,
+ authUI: FirebaseAuthUI,
+ mode: EmailAuthMode?,
+ changeMode: (EmailAuthMode) -> Unit,
+ signInPreference: SignInPreferenceManager.SignInPreference? = null,
+ credentialForLinking: AuthCredential? = null,
+ emailLinkFromDifferentDevice: String? = null,
+ onSuccess: () -> Unit,
+ onError: (AuthException) -> Unit,
+ onCancel: () -> Unit,
+ content: @Composable ((EmailAuthContentState) -> Unit)? = null,
+) {
+ val provider = configuration.providers.filterIsInstance().first()
+ val coroutineScope = rememberCoroutineScope()
+
+ // Start in EmailLinkSignIn mode if coming from cross-device flow
+ val safeMode = mode
+ ?: if (emailLinkFromDifferentDevice != null && provider.isEmailLinkSignInEnabled) {
+ EmailAuthMode.EmailLinkSignIn
+ } else {
+ EmailAuthMode.SignIn
+ }
+ val displayNameValue = rememberSaveable { mutableStateOf("") }
+ // The user has chosen to continue with a specific email address, so pre-populate it. Note that
+ // it might get overwritten with a value retrieved from credential manager in SignInUI.
+ val emailTextValue = rememberSaveable { mutableStateOf(signInPreference?.identifier.orEmpty()) }
+ val passwordTextValue = rememberSaveable { mutableStateOf("") }
+ val confirmPasswordTextValue = rememberSaveable { mutableStateOf("") }
+
+ 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) {
+ // Timber.d("Current state: $authState")
+ when (val state = authState) {
+ is AuthState.Success -> {
+ onSuccess()
+ }
+
+ is AuthState.Error -> {
+ val exception = AuthException.from(state.exception)
+ onError(exception)
+ }
+
+ is AuthState.Cancelled -> {
+ onCancel()
+ }
+
+ else -> Unit
+ }
+ }
+
+ val state = EmailAuthContentState(
+ mode = safeMode,
+ 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,
+ provider = provider,
+ 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,
+ provider = provider,
+ email = emailTextValue.value,
+ emailLink = emailLinkFromDifferentDevice,
+ )
+ } else {
+ authUI.sendSignInLinkToEmail(
+ context = context,
+ provider = provider,
+ email = emailTextValue.value,
+ credentialForLinking = authCredentialForLinking,
+ )
+ }
+ } catch (e: Exception) {
+ onError(AuthException.from(e))
+ }
+ }
+ },
+ onSignUpClick = {
+ coroutineScope.launch {
+ try {
+ authUI.createUserWithEmailAndPassword(
+ context = context,
+ config = configuration,
+ provider = provider,
+ name = displayNameValue.value,
+ email = emailTextValue.value,
+ password = passwordTextValue.value,
+ )
+ } catch (_: Exception) {
+
+ }
+ }
+ },
+ onSendResetLinkClick = {
+ coroutineScope.launch {
+ try {
+ authUI.sendPasswordResetEmail(
+ email = emailTextValue.value,
+ actionCodeSettings = configuration.passwordResetActionCodeSettings,
+ )
+ } catch (_: Exception) {
+
+ }
+ }
+ },
+ onResetLinkSentConfirmed = {
+ // Reset state set by sendPasswordResetEmail so confirmation dialog isn't shown again
+ authUI.updateAuthState(AuthState.Idle)
+ changeMode(EmailAuthMode.SignIn)
+ },
+ onGoToSignUp = {
+ changeMode(EmailAuthMode.SignUp)
+ },
+ onGoToSignIn = {
+ changeMode(EmailAuthMode.SignIn)
+ },
+ onGoToResetPassword = {
+ // Password must be incorrect, so clear it
+ passwordTextValue.value = ""
+ changeMode(EmailAuthMode.ResetPassword)
+ },
+ onGoToEmailLinkSignIn = {
+ changeMode(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,
+ 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(
+ isLoading = state.isLoading,
+ email = state.email,
+ onEmailChange = state.onEmailChange,
+ onSendResetLink = state.onSendResetLinkClick,
+ isConfirmationDialogVisible = state.resetLinkSent,
+ onConfirmationDialogDismissed = state.onResetLinkSentConfirmed,
+ 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..38511c562f
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt
@@ -0,0 +1,168 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.screens.email
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+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.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+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.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+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.ui.components.AuthEmailTextField
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTopAppBar
+import com.battlelancer.seriesguide.backend.auth.ui.components.BoxWithCenteredColumn
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ResetPasswordUI(
+ modifier: Modifier = Modifier,
+ isLoading: Boolean,
+ email: String,
+ onEmailChange: (String) -> Unit,
+ onSendResetLink: () -> Unit,
+ isConfirmationDialogVisible: Boolean,
+ onConfirmationDialogDismissed: () -> Unit,
+ onNavigateBack: (() -> Unit)? = null,
+) {
+ val stringProvider = LocalAuthUIStringProvider.current
+ val emailValidator = remember {
+ EmailValidator(stringProvider)
+ }
+
+ val isFormValid = remember(email) {
+ derivedStateOf { emailValidator.validate(email) }
+ }
+
+ // Note: currently don't need to remember dialog visible state as on dismissal leaving this
+ // screen, see dismiss callback.
+ if (isConfirmationDialogVisible) {
+ AlertDialog(
+ title = {
+ Text(
+ text = stringProvider.recoverPasswordLinkSentDialogTitle,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Text(
+ text = stringProvider.recoverPasswordLinkSentDialogBody,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Start
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onConfirmationDialogDismissed()
+ }
+ ) {
+ Text(stringProvider.dismissAction)
+ }
+ },
+ onDismissRequest = {
+ onConfirmationDialogDismissed()
+ }
+ )
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ AuthTopAppBar(
+ title = stringProvider.resetPasswordAction,
+ onNavigateBack = onNavigateBack
+ )
+ },
+ ) { innerPadding ->
+ BoxWithCenteredColumn(
+ insetPadding = innerPadding
+ ) {
+ AuthEmailTextField(
+ value = email,
+ validator = emailValidator,
+ enabled = !isLoading,
+ onValueChange = { text ->
+ onEmailChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .align(Alignment.End),
+ ) {
+ TextButton(
+ onClick = {
+ onConfirmationDialogDismissed()
+ },
+ enabled = !isLoading,
+ ) {
+ Text(stringProvider.signInDefault)
+ }
+ 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)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewResetPasswordUI() {
+ val applicationContext = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
+ ) {
+ ResetPasswordUI(
+ email = "someone@domain.example",
+ isLoading = false,
+ onEmailChange = { _ -> },
+ onSendResetLink = {},
+ isConfirmationDialogVisible = true,
+ onConfirmationDialogDismissed = {},
+ )
+ }
+ }
+}
\ 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..130e072c25
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt
@@ -0,0 +1,218 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.screens.email
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+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.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+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.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.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.ui.components.AuthEmailTextField
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthHorizontalDivider
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTopAppBar
+import com.battlelancer.seriesguide.backend.auth.ui.components.BoxWithCenteredColumn
+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 = {
+ AuthTopAppBar(
+ title = stringProvider.signInDefault,
+ onNavigateBack = onNavigateBack
+ )
+ },
+ ) { innerPadding ->
+ BoxWithCenteredColumn(
+ insetPadding = innerPadding
+ ) {
+ AuthEmailTextField(
+ value = email,
+ validator = emailValidator,
+ enabled = !isLoading,
+ 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.resetPasswordAction,
+ 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)
+ }
+ }
+
+ // Show toggle to go back to password mode
+ AuthHorizontalDivider()
+ Button(
+ onClick = {
+ onGoToSignIn()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading
+ ) {
+ Text(stringProvider.signInWithPassword)
+ }
+ }
+ }
+}
+
+@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
+ )
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
+ ) {
+ SignInEmailLinkUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ privacyPolicyUrl = ""
+ },
+ email = "",
+ isLoading = false,
+ emailSignInLinkSent = false,
+ onEmailChange = { _ -> },
+ 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..08fd216c8d
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt
@@ -0,0 +1,283 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.screens.email
+
+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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+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.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.runtime.saveable.rememberSaveable
+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.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.AuthEmailTextField
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthHorizontalDivider
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthPasswordTextField
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthShowPasswordToggle
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTopAppBar
+import com.battlelancer.seriesguide.backend.auth.ui.components.BoxWithCenteredColumn
+import com.google.firebase.auth.actionCodeSettings
+import timber.log.Timber
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SignInUI(
+ modifier: Modifier = Modifier,
+ configuration: AuthUIConfiguration,
+ isLoading: 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 showPassword = rememberSaveable { mutableStateOf(false) }
+
+ 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()
+
+ Timber.d("Retrieved credential for: %s", 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))
+
+ // Just fill, let user confirm filled credentials and manually trigger sign in
+ } catch (_: PasswordCredentialNotFoundException) {
+ Timber.d("No saved credentials found")
+ // No credentials saved - user will enter manually
+ } catch (_: PasswordCredentialCancelledException) {
+ Timber.d("User cancelled credential selection")
+ // User cancelled - let them enter manually
+ } catch (e: PasswordCredentialException) {
+ Timber.w(e, "Failed to retrieve credentials")
+ // Failed to retrieve - let them enter manually
+ }
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ AuthTopAppBar(
+ title = stringProvider.signInDefault,
+ onNavigateBack = onNavigateBack
+ )
+ },
+ ) { innerPadding ->
+ BoxWithCenteredColumn(
+ insetPadding = innerPadding
+ ) {
+ AuthEmailTextField(
+ value = email,
+ validator = emailValidator,
+ enabled = !isLoading,
+ onValueChange = { text ->
+ onEmailChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthPasswordTextField(
+ value = password,
+ validator = passwordValidator,
+ enabled = !isLoading,
+ textVisible = showPassword.value,
+ onValueChange = { text ->
+ onPasswordChange(text)
+ }
+ )
+ AuthShowPasswordToggle(
+ value = showPassword.value,
+ onValueChange = { newValue ->
+ showPassword.value = newValue
+ }
+ )
+ TextButton(
+ modifier = Modifier
+ .align(Alignment.Start),
+ onClick = {
+ onGoToResetPassword()
+ },
+ enabled = !isLoading,
+ contentPadding = PaddingValues.Zero
+ ) {
+ Text(stringProvider.resetPasswordAction)
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ modifier = Modifier
+ .align(Alignment.End),
+ ) {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
+ TooltipAnchorPosition.Above
+ ),
+ tooltip = {
+ PlainTooltip {
+ Text(stringProvider.newAccountsDisabled)
+ }
+ },
+ state = rememberTooltipState(
+ initialIsVisible = !provider.isNewAccountsAllowed
+ )
+ ) {
+ TextButton(
+ onClick = {
+ onGoToSignUp()
+ },
+ enabled = provider.isNewAccountsAllowed && !isLoading,
+ ) {
+ Text(stringProvider.signupPageTitle)
+ }
+ }
+ 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)
+ }
+ }
+ }
+
+ // Show toggle to email link sign-in
+ if (provider.isEmailLinkSignInEnabled) {
+ AuthHorizontalDivider()
+ Button(
+ onClick = {
+ onGoToEmailLinkSignIn()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading
+ ) {
+ Text(stringProvider.signInWithEmailLink)
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewSignInUI() {
+ val applicationContext = LocalContext.current
+ val provider = AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkSignInEnabled = true,
+ isEmailLinkForceSameDeviceEnabled = true,
+ emailLinkActionCodeSettings = actionCodeSettings {
+ url = ""
+ handleCodeInApp = true
+ },
+ isNewAccountsAllowed = false
+ )
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
+ ) {
+ SignInUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ privacyPolicyUrl = ""
+ },
+ email = "",
+ password = "",
+ isLoading = false,
+ onEmailChange = { _ -> },
+ onPasswordChange = { _ -> },
+ onRetrievedCredential = { _ -> },
+ 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..8c40326e50
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt
@@ -0,0 +1,232 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.screens.email
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+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.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.GeneralFieldValidator
+import com.battlelancer.seriesguide.backend.auth.configuration.validators.PasswordValidator
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthEmailTextField
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthPasswordTextField
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthShowPasswordToggle
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTextField
+import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTopAppBar
+import com.battlelancer.seriesguide.backend.auth.ui.components.BoxWithCenteredColumn
+
+@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 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 showPassword = rememberSaveable { mutableStateOf(false) }
+
+ 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 = {
+ AuthTopAppBar(
+ title = stringProvider.signupPageTitle,
+ onNavigateBack = onNavigateBack
+ )
+ },
+ ) { innerPadding ->
+ BoxWithCenteredColumn(
+ insetPadding = innerPadding
+ ) {
+ if (provider.isDisplayNameRequired) {
+ AuthTextField(
+ value = displayName,
+ validator = displayNameValidator,
+ enabled = !isLoading,
+ label = {
+ Text(stringProvider.nameHint)
+ },
+ onValueChange = { text ->
+ onDisplayNameChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ AuthEmailTextField(
+ value = email,
+ validator = emailValidator,
+ enabled = !isLoading,
+ onValueChange = { text ->
+ onEmailChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthPasswordTextField(
+ value = password,
+ validator = passwordValidator,
+ enabled = !isLoading,
+ textVisible = showPassword.value,
+ onValueChange = { text ->
+ onPasswordChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthPasswordTextField(
+ value = confirmPassword,
+ validator = confirmPasswordValidator,
+ enabled = !isLoading,
+ textVisible = showPassword.value,
+ label = {
+ Text(stringProvider.confirmPasswordHint)
+ },
+ onValueChange = { text ->
+ onConfirmPasswordChange(text)
+ }
+ )
+ AuthShowPasswordToggle(
+ value = showPassword.value,
+ onValueChange = { newValue ->
+ showPassword.value = newValue
+ }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .align(Alignment.End),
+ ) {
+ TextButton(
+ onClick = {
+ onGoToSignIn()
+ },
+ enabled = !isLoading,
+ ) {
+ Text(stringProvider.signInDefault)
+ }
+ 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)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewSignUpUI() {
+ val applicationContext = LocalContext.current
+ val provider = AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkSignInEnabled = false,
+ isEmailLinkForceSameDeviceEnabled = true,
+ emailLinkActionCodeSettings = null,
+ isNewAccountsAllowed = true
+ )
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
+ ) {
+ SignUpUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ privacyPolicyUrl = ""
+ },
+ isLoading = false,
+ displayName = "",
+ email = "",
+ password = "",
+ confirmPassword = "",
+ onDisplayNameChange = { _ -> },
+ onEmailChange = { _ -> },
+ onPasswordChange = { _ -> },
+ onConfirmPasswordChange = { _ -> },
+ onSignUpClick = {},
+ onGoToSignIn = {}
+ )
+ }
+ }
+}
\ No newline at end of file
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..dac1a42231
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.util
+
+import androidx.annotation.RestrictTo
+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.
+ * 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 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/CredentialPersistenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt
new file mode 100644
index 0000000000..fd77c128b8
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt
@@ -0,0 +1,78 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.util
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+import timber.log.Timber
+
+private val Context.credentialDataStore: DataStore by preferencesDataStore(
+ name = "seriesguide.auth.credentialmanager",
+ corruptionHandler = ReplaceFileCorruptionHandler { corruptionException ->
+ Timber.e(
+ corruptionException,
+ "Reading credential manager preferences failed, clearing file"
+ )
+ emptyPreferences()
+ }
+)
+
+/**
+ * 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..79ee2ab949
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt
@@ -0,0 +1,56 @@
+// 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
+
+/**
+ * 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 = "seriesguide.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..01201e1cee
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.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 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 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..2ab31b8a71
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt
@@ -0,0 +1,136 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.util
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Provider
+import com.google.firebase.auth.AuthCredential
+import com.google.firebase.auth.GoogleAuthProvider
+import kotlinx.coroutines.flow.first
+import timber.log.Timber
+
+private val Context.emailLinkDataStore: DataStore by preferencesDataStore(
+ name = "seriesguide.auth.emaillink",
+ corruptionHandler = ReplaceFileCorruptionHandler { corruptionException ->
+ Timber.e(corruptionException, "Reading email link preferences failed, clearing file")
+ emptyPreferences()
+ }
+)
+
+/**
+ * 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
+ * - Social provider credentials for linking flows
+ *
+ * @since 10.0.0
+ */
+object EmailLinkPersistenceManager {
+
+ val KEY_EMAIL = stringPreferencesKey("email")
+ val KEY_PROVIDER = stringPreferencesKey("provider")
+ val KEY_SESSION_ID = stringPreferencesKey("sid")
+ val KEY_IDP_TOKEN = stringPreferencesKey("idpToken")
+ val KEY_IDP_SECRET = stringPreferencesKey("idpSecret")
+
+ /**
+ * 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
+ ) {
+ context.emailLinkDataStore.edit { prefs ->
+ prefs[KEY_EMAIL] = email
+ prefs[KEY_SESSION_ID] = sessionId
+ }
+ }
+
+ override suspend fun saveCredentialForLinking(
+ context: Context,
+ providerType: String,
+ idToken: String?,
+ accessToken: String?
+ ) {
+ context.emailLinkDataStore.edit { prefs ->
+ prefs[KEY_PROVIDER] = providerType
+ prefs[KEY_IDP_TOKEN] = idToken ?: ""
+ prefs[KEY_IDP_SECRET] = accessToken ?: ""
+ }
+ }
+
+ override suspend fun retrieveSessionRecord(context: Context): SessionRecord? {
+ val prefs = context.emailLinkDataStore.data.first()
+ val email = prefs[KEY_EMAIL]
+ val sessionId = prefs[KEY_SESSION_ID]
+
+ if (email == null || sessionId == null) {
+ return null
+ }
+
+ val providerType = Provider.fromId(prefs[KEY_PROVIDER])
+ val idToken = prefs[KEY_IDP_TOKEN]
+ val accessToken = prefs[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)
+ else -> null
+ }
+ } else {
+ null
+ }
+
+ return SessionRecord(
+ sessionId = sessionId,
+ email = email,
+ credentialForLinking = credentialForLinking
+ )
+ }
+
+ override suspend fun clear(context: Context) {
+ context.emailLinkDataStore.edit { prefs ->
+ prefs.remove(KEY_SESSION_ID)
+ prefs.remove(KEY_EMAIL)
+ prefs.remove(KEY_PROVIDER)
+ prefs.remove(KEY_IDP_TOKEN)
+ prefs.remove(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 credentialForLinking Optional social provider credential to link after sign-in
+ */
+ data class SessionRecord(
+ val sessionId: String,
+ val email: 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..5e81e79567
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt
@@ -0,0 +1,63 @@
+// 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 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
+ */
+ suspend fun saveEmail(
+ context: Context,
+ email: String,
+ sessionId: 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/SessionUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt
new file mode 100644
index 0000000000..8089f0b544
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt
@@ -0,0 +1,31 @@
+// 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 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..c643a7776a
--- /dev/null
+++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt
@@ -0,0 +1,127 @@
+// SPDX-License-Identifier: Apache-2.0 AND GPL-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.util
+
+import android.content.Context
+import android.util.Log
+import androidx.datastore.core.DataStore
+import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+import timber.log.Timber
+
+private val Context.signInPreferenceDataStore: DataStore by preferencesDataStore(
+ name = "seriesguide.auth.signin",
+ corruptionHandler = ReplaceFileCorruptionHandler { corruptionException ->
+ Timber.e(corruptionException, "Reading sign-in preferences failed, clearing file")
+ emptyPreferences()
+ }
+)
+
+/**
+ * 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")
+
+ /**
+ * Tries to save the last-used sign-in method and user identifier for the "Continue as..."
+ * feature. On failure will just log.
+ *
+ * This should be called after a successful sign-in to track the user's
+ * preferred sign-in method.
+ *
+ * @param providerId The AuthProvider.providerId
+ * @param identifier The user identifier (email)
+ */
+ suspend fun tryToSaveLastSignIn(
+ context: Context,
+ providerId: String,
+ identifier: String?,
+ logTag: String
+ ) {
+ try {
+ context.signInPreferenceDataStore.edit { prefs ->
+ prefs[KEY_LAST_PROVIDER_ID] = providerId
+ identifier?.let { prefs[KEY_LAST_IDENTIFIER] = it }
+ prefs[KEY_LAST_TIMESTAMP] = System.currentTimeMillis()
+ }
+ Timber.tag(logTag).d("Sign-in preference saved for: %s", identifier)
+ } catch (e: Exception) {
+ // Failed to save preference - log but don't break auth flow
+ Timber.tag(logTag).w(e, "Failed to save sign-in preference for: %s", identifier)
+ }
+ }
+
+ /**
+ * 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
+ )
+}
diff --git a/app/src/main/java/com/battlelancer/seriesguide/util/Errors.kt b/app/src/main/java/com/battlelancer/seriesguide/util/Errors.kt
index 16bf466161..86f41980fd 100644
--- a/app/src/main/java/com/battlelancer/seriesguide/util/Errors.kt
+++ b/app/src/main/java/com/battlelancer/seriesguide/util/Errors.kt
@@ -133,14 +133,13 @@ class Errors {
getReporter()?.recordException(throwable)
}
- fun logAndReportHexagonAuthError(throwable: HexagonAuthError) {
- Timber.e(throwable, throwable.action)
+ fun reportHexagonAuthError(action: String, throwable: Throwable) {
+ val hexagonAuthException = HexagonAuthError.build(action, throwable)
- bendCauseStackTrace(throwable)
+ bendCauseStackTrace(hexagonAuthException)
- getReporter()?.setCustomKey("action", throwable.action)
- throwable.statusCode?.let { getReporter()?.setCustomKey("code", it) }
- getReporter()?.recordException(throwable)
+ getReporter()?.setCustomKey("action", hexagonAuthException.action)
+ getReporter()?.recordException(hexagonAuthException)
}
/**
diff --git a/app/src/main/res/drawable/ic_account_circle_black_24dp.xml b/app/src/main/res/drawable/ic_account_circle_on_surface_light_24dp.xml
similarity index 89%
rename from app/src/main/res/drawable/ic_account_circle_black_24dp.xml
rename to app/src/main/res/drawable/ic_account_circle_on_surface_light_24dp.xml
index 328f0a6495..e1d3b7b397 100644
--- a/app/src/main/res/drawable/ic_account_circle_black_24dp.xml
+++ b/app/src/main/res/drawable/ic_account_circle_on_surface_light_24dp.xml
@@ -1,10 +1,10 @@
+
diff --git a/app/src/main/res/drawable/ic_arrow_back_control_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_control_24dp.xml
new file mode 100644
index 0000000000..1dc39b7cd5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_arrow_back_control_24dp.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_email_black_24dp.xml b/app/src/main/res/drawable/ic_email_black_24dp.xml
deleted file mode 100644
index 399b7c123c..0000000000
--- a/app/src/main/res/drawable/ic_email_black_24dp.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_email_control_24dp.xml b/app/src/main/res/drawable/ic_email_control_24dp.xml
new file mode 100644
index 0000000000..ae33df450c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_email_control_24dp.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_email_white_24dp.xml b/app/src/main/res/drawable/ic_email_white_24dp.xml
new file mode 100644
index 0000000000..a42eef1f5e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_email_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_rounded_password_control_24dp.xml b/app/src/main/res/drawable/ic_rounded_password_control_24dp.xml
new file mode 100644
index 0000000000..544558ef5b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_rounded_password_control_24dp.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_visibility_control_24dp.xml b/app/src/main/res/drawable/ic_visibility_control_24dp.xml
index 8d37e8f58e..1c34e31dd1 100644
--- a/app/src/main/res/drawable/ic_visibility_control_24dp.xml
+++ b/app/src/main/res/drawable/ic_visibility_control_24dp.xml
@@ -2,9 +2,9 @@
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
- android:viewportWidth="24.0"
- android:viewportHeight="24.0">
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ android:pathData="M607.5,587.5Q660,535 660,460Q660,385 607.5,332.5Q555,280 480,280Q405,280 352.5,332.5Q300,385 300,460Q300,535 352.5,587.5Q405,640 480,640Q555,640 607.5,587.5ZM403.5,536.5Q372,505 372,460Q372,415 403.5,383.5Q435,352 480,352Q525,352 556.5,383.5Q588,415 588,460Q588,505 556.5,536.5Q525,568 480,568Q435,568 403.5,536.5ZM235.5,688Q125,616 61,498Q56,489 53.5,479.5Q51,470 51,460Q51,450 53.5,440.5Q56,431 61,422Q125,304 235.5,232Q346,160 480,160Q614,160 724.5,232Q835,304 899,422Q904,431 906.5,440.5Q909,450 909,460Q909,470 906.5,479.5Q904,489 899,498Q835,616 724.5,688Q614,760 480,760Q346,760 235.5,688ZM480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM687.5,620.5Q782,561 832,460Q782,359 687.5,299.5Q593,240 480,240Q367,240 272.5,299.5Q178,359 128,460Q178,561 272.5,620.5Q367,680 480,680Q593,680 687.5,620.5Z" />
diff --git a/app/src/main/res/drawable/ic_visibility_off_control_24dp.xml b/app/src/main/res/drawable/ic_visibility_off_control_24dp.xml
index 38cd954192..e58c1c048a 100644
--- a/app/src/main/res/drawable/ic_visibility_off_control_24dp.xml
+++ b/app/src/main/res/drawable/ic_visibility_off_control_24dp.xml
@@ -1,13 +1,10 @@
-
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ android:pathData="M607,333Q636,362 649.5,399Q663,436 659,475Q659,490 648,500.5Q637,511 622,511Q607,511 596.5,500.5Q586,490 586,475Q591,449 583,425Q575,401 558,384Q541,367 517,358Q493,349 466,354Q451,354 440.5,343Q430,332 430,317Q430,302 440.5,291.5Q451,281 466,281Q504,277 541,290.5Q578,304 607,333ZM480,240Q461,240 443,241.5Q425,243 407,247Q390,250 376.5,242Q363,234 358,218Q353,202 361.5,187Q370,172 386,169Q409,164 432.5,162Q456,160 480,160Q617,160 730.5,232Q844,304 904,426Q908,434 910,442.5Q912,451 912,460Q912,469 910.5,477.5Q909,486 905,494Q887,534 860.5,569Q834,604 802,633Q790,644 774,642Q758,640 748,626Q738,612 739.5,595.5Q741,579 753,568Q777,545 797,518Q817,491 832,460Q782,359 687.5,299.5Q593,240 480,240ZM480,760Q346,760 235,687.5Q124,615 60,497Q55,489 52.5,479.5Q50,470 50,460Q50,450 52,441Q54,432 59,423Q79,383 105.5,346.5Q132,310 166,280L83,196Q72,184 72.5,167.5Q73,151 84,140Q95,129 112,129Q129,129 140,140L820,820Q831,831 831.5,847.5Q832,864 820,876Q809,887 792,887Q775,887 764,876L624,738Q589,749 553,754.5Q517,760 480,760ZM222,336Q193,362 169,393Q145,424 128,460Q178,561 272.5,620.5Q367,680 480,680Q500,680 519,677.5Q538,675 558,672L522,634Q511,637 501,638.5Q491,640 480,640Q405,640 352.5,587.5Q300,535 300,460Q300,449 301.5,439Q303,429 306,418L222,336ZM541,429L541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429ZM390,504Q390,504 390,504Q390,504 390,504L390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Z" />
diff --git a/app/src/main/res/layout/auth_picker_email_google.xml b/app/src/main/res/layout/auth_picker_email_google.xml
deleted file mode 100644
index 7b5e16d3f0..0000000000
--- a/app/src/main/res/layout/auth_picker_email_google.xml
+++ /dev/null
@@ -1,81 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 3e82dc4031..44cdd21718 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -368,8 +368,6 @@
إزالة حسابك ستؤدي إلى إلغاء كل مسلسلاتك وأفلامك من سحابة SeriesGuide. تحقق من إزالته من الأجهزة الأخرى المتصلة أيضًا.
جارٍ الإرسال إلى سحابة SeriesGuide.
تسجيل الخروج من سحابة SeriesGuide.
- خطأ في تسجيل الدخول (%s)
- يتطلب عنوان البريد الإلكتروني تسجيل دخول Google
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
جارٍ الإرسال إلى Trakt.
diff --git a/app/src/main/res/values-b+sr+Latn/strings.xml b/app/src/main/res/values-b+sr+Latn/strings.xml
index a7b7a49c2c..77076398fa 100644
--- a/app/src/main/res/values-b+sr+Latn/strings.xml
+++ b/app/src/main/res/values-b+sr+Latn/strings.xml
@@ -338,8 +338,6 @@
Ovo će izbrisati vaše serije, liste i filmove iz SeriesGuide Cloud-a
Slanje u SeriesGuide Cloud
Odjavljeni ste iz SeriesGuide Cloud-a
- Greška pri prijavljivanju (%s)
- Adresa e-pošte zahteva prijavljivanje na Google
Izaberite imejl za prijavu ako koristite SeriesGuide na drugim uređajima koji ne podržavaju prijavljivanje na Google.
Poslato na Trakt
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index 5fdf0fe294..558dd720ee 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -328,8 +328,6 @@
С премахването на акаунта ви ще се изтрият всички предавания и филми от SeriesGuide облака. Не забраявайте да го премахнете и на други свързани устройства.
Изпращане към SeriesGuide облака.
Излизане от облака SeriesGuide.
- Грешка при влизане с (%s)
- Email адресът изисква Google Sign-In
Изберете имейл за вход, ако използвате SeriesGuide на устройства, които не поддържат Google акаунти.
Подаване към trakt.
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index de39fc517c..bde84da112 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -328,8 +328,6 @@
Si esborreu el vostre compte, també s\'esborraran totes les sèries i peŀlícules que tingueu al Cloud de SeriesGuide. Assegureu-vos d\'esborrar-ho dels altres dispositius on el tingueu sincronitzat.
Enviant a SeriesGuide Cloud.
Has desconnectat de SeriesGuide Cloud.
- Error amb l\'inici de sessió (%s)
- L\'adreça de correu electrònic requereix iniciar sessió amb Google
Trieu el correu electrònic per iniciar sessió en cas que s\'utilitzi SeriesGuide en altres dispositius que no permetin l\'inici de sessió amb Google.
S\'està enviant a trakt.
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 97a1312ae6..97b34626cb 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -348,8 +348,6 @@
Tímto smažete vaše seriály, seznamy a filmy ze SeriesGuide cloudu.
Odesílání do SeriesGuide Cloud.
Odhlášeno z SeriesGuide Cloudu.
- Chyba při přihlášení (%s)
- E-mailová adresa vyžaduje přihlášení Google
Pokud používáte SeriesGuide na jiných zařízeních, která nepodporují přihlášení Google, zvolte e-mail pro přihlášení.
Odesílání na Trakt.
diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml
index b512cc27c0..bb0efd73fa 100644
--- a/app/src/main/res/values-cy/strings.xml
+++ b/app/src/main/res/values-cy/strings.xml
@@ -368,8 +368,6 @@
Bydd hyn yn dileu eich sioeau, rhestrau a ffilmiau o SeriesGuide Cloud
Anfon i SeriesGuide Cloud.
Llofnodwyd allan o SeriesGuide Cloud
- Gwall wrth fewngofnodi (%s)
- Mae cyfeiriad Google yn gofyn am Google Sign-In
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
Anfon at Trakt.
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
index ea3d09015b..974c103ba6 100644
--- a/app/src/main/res/values-da/strings.xml
+++ b/app/src/main/res/values-da/strings.xml
@@ -328,8 +328,6 @@
Hvis du sletter din konto, slettes alle film og serier fra SeriesGuide Cloud. Sørg for også at fjerne kontoen på andre tilsluttede enheder.
Sender til SeriesGuide Cloud.
Logget ud af SeriesGuide Cloud.
- Fejl i login (%s)
- E-mail-adresse kræver Google Sign-In
Vælg e-mail til login, hvis du bruger SeriesGuide på andre enheder, der ikke understøtter Google-login.
Sender til trakt.
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 107ebf0eab..3b04547f8b 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -328,8 +328,6 @@
Dies entfernt Ihre Serien, Listen und Filme aus SeriesGuide Cloud
Sende an SeriesGuide Cloud
Von SeriesGuide Cloud abgemeldet
- Fehler beim Anmelden (%s)
- E-Mail-Adresse erfordert Anmeldung mit Google
Wählen Sie E-Mail zum Anmelden wenn Sie SeriesGuide auf anderen Geräten verwenden die keine Anmeldung mit Google unterstützen.
Sende an Trakt.
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index bc69a803f2..79aa4c6683 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -328,8 +328,6 @@
Η αφαίρεση του λογαριασμού σας θα διαγράψει όλες τις σειρές και τις ταινίες από το SeriesGuide cloud. Σιγουρευτείτε ότι θα τον αφαιρέσετε και από τις άλλες συνδεδεμένες συσκευές σας.
Αποστολή στο SeriesGuide Cloud.
Αποσυνδεθείτε από το SeriesGuide Cloud.
- Σφάλμα με την είσοδο (%s)
- Η διεύθυνση ηλεκτρονικού ταχυδρομείου απαιτεί είσοδο στο Google
Επιλέξτε τη σύνδεση μέσω email αν χρησιμοποιείτε το SeriesGuide σε άλλες συσκευές που δεν υποστηρίζουν τη σύνδεση μέσω Google.
Υποβολή στο trakt.
diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml
index f29a7d03be..1c3060376a 100644
--- a/app/src/main/res/values-eo/strings.xml
+++ b/app/src/main/res/values-eo/strings.xml
@@ -328,8 +328,6 @@
This will delete your shows, lists and movies from SeriesGuide Cloud
Sendas al SeriesGuide Cloud.
Elsalutis el SeriesGuide Cloud
- Error with sign-in (%s)
- Email address requires Google Sign-In
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
Sendado al Trakt.
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 026ab33930..e9cd760287 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -329,8 +329,6 @@
Asegúrate de quitarla también en tus otros dispositivos conectados.
Enviando a SeriesGuide Cloud.
Desconectado de SeriesGuide Cloud.
- Error al iniciar sesión (%s)
- La dirección de correo electrónico requiere inicio de sesión de Google
Elija el correo electrónico para iniciar sesión si está utilizando SeriesGuide en otros dispositivos que no soporten el inicio de sesión de Google.
Enviando a trakt.
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index 34608e30e9..850c4da902 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -328,8 +328,6 @@
حذف حسابتان تمام نمایشها و فیلمها را از فضای ابری SeriesGuide پاک میکند. مطمئن شوید که آن را از دیگر افزارههای وصل شده نیز حذف میکنید.
در حال ارسال به فضای ابری SeriesGuide.
از فضای ابری SeriesGuide خارج شدید.
- خطا در ورود (%s)
- نشانی رایانامه نیازمند ورود گوگلی است
اگر روی افزارهای دیگر که از ورود گوگل پشتیبانی نمیکند از سریزگاید استفاده میکنید رایانامه را برای ورود برگزینید.
ارسال به trakt.
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index b82ec79a6b..7ddb793040 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -328,8 +328,6 @@
Tämä poistaa ohjelmasi, listasi ja elokuvasi SeriesGuide Cloudista
Lähetetään SeriesGuide Cloudiin.
Kirjauduttu ulos SeriesGuide Cloudista.
- Virhe sisäänkirjautumisessa (%s)
- Sähköpostiosoite vaatii Googlen sisäänkirjautumisen
Valitse sähköpostiosoite, jolla kirjaudut sisään, jos käytät SeriesGuidea muilla laitteilla, jotka eivät tue Google-kirjautumista.
Lähetetään Traktiin.
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index d3976dc673..65ecaa8b10 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -328,8 +328,6 @@
Retirer votre compte supprimera tous les films et séries se trouvant sur le cloud SeriesGuide. Assurez-vous de le retirer également sur vos autres appareils connectés.
Envoi vers SeriesGuide Cloud.
Déconnecté du cloud SeriesGuide.
- Erreur lors de la connexion (%s)
- L\'adresse e-mail nécessite une connexion à Google
Choisissez l\'e-mail pour vous connecter si vous utilisez SeriesGuide sur d\'autres appareils qui ne prennent pas en charge la connexion Google.
Envoi vers trakt.
diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml
index 2dfea6bf42..f180b66e19 100644
--- a/app/src/main/res/values-gl/strings.xml
+++ b/app/src/main/res/values-gl/strings.xml
@@ -328,8 +328,6 @@
Isto borrará as túas series, listas e películas de SeriesGuides Cloud
Enviando a SeriesGuide Cloud.
Desconectado de SeriesGuide Cloud
- Error with sign-in (%s)
- Email address requires Google Sign-In
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
Enviando a trakt.
diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml
index d840ac8c1d..3040a9d4f7 100644
--- a/app/src/main/res/values-hr/strings.xml
+++ b/app/src/main/res/values-hr/strings.xml
@@ -338,8 +338,6 @@
Uklanjanjem svog računa će te izbrisati sve serije i filmove sa SeriesGuide Oblaka. Ne zaboravite ih ukloniti i sa ostalih povezanih uređaja.
Slanje na SeriesGuide oblak.
Odjavljeni ste sa SeriesGuide oblaka.
- Error with sign-in (%s)
- Email address requires Google Sign-In
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
Poslano na trakt.
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index cd55aaa0b6..1cafc89d00 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -328,8 +328,6 @@
A fiókod eltávolításával minden sorozat és film törlődni fog a SeriesGuide Cloudról. Gondoskodj minden más eszközről való törléséről is.
Küldés a SeriesGuide felhő tárhelyre.
Kijelentkezett a SeriesGuide felhő tárhelyről.
- Hiba történt a Bejelentkezéssel (%s)
- E-mail címhez Google bejelentkezés szükséges
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
Elküldés a traktnak.
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index a7f19a65d9..97e737ca66 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -318,8 +318,6 @@
Menghapus akun anda akan menghapus semua acara dan film dari SeriesGuide Cloud. Pastikan untuk menghapusnya dari perangkat terhubung lainnya juga.
Mengirim ke SeriesGuide Cloud.
Keluar dari SeriesGuide Cloud.
- Error dengan masuk (%s)
- Alamat email membutuhkan masuk dengan Google
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
Mengirim ke trakt.
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index c4cfbe7eff..53ff852650 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -328,8 +328,6 @@
Rimuovendo il tuo account verranno eliminati tutti i film e le serie TV dalla SeriesGuide Cloud. Assicurati di rimuoverlo anche su tutti gli altri dispositivi connessi.
Invio a SeriesGuide Cloud.
Disconnesso da SeriesGuide Cloud.
- Errore di accesso (%s)
- L\' indirizzo e-mail richiede l\'accesso a Google
Scegli l\'email per accedere se stai usando SeriesGuide su altri dispositivi che non supportano l\'autenticazione Google.
Invio a trakt.
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
index f466951ab0..069722a29b 100644
--- a/app/src/main/res/values-iw/strings.xml
+++ b/app/src/main/res/values-iw/strings.xml
@@ -348,8 +348,6 @@
הסרת החשבון שלך תמחק את כל התוכניות והסרטים מ-SeriesGuide Cloud. הקפד למחוק אותם גם בהתקנים מחוברים אחרים.
שולח ל-SeriesGuide Cloud.
אינך מחובר לענן SeriesGuide.
- שגיאה בכניסה (%s)
- כתובת אימייל מחייבת כניסה באמצעות Google
בחר אימייל כדי להיכנס אם אתה משתמש ב-SeriesGuide במכשירים אחרים שלא תומכים בכניסה באמצעות Google.
שלח ל-trakt.
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 2bad1ff3f6..74ac1024e6 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -318,8 +318,6 @@
SeriesGuide Cloud からすべての番組や映画が削除されます
SeriesGuide クラウドに送信しています。
SeriesGuide クラウドからサインアウトします。
- Error with sign-in (%s)
- Email address requires Google Sign-In
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
Trakt に送信しています
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 5c652d3782..771e957a7e 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -318,8 +318,6 @@
계정을 삭제하면 SeriesGuide Cloud에서 모든 프로그램과 영화가 제거됩니다.
SeriesGuide Cloud로 전송합니다.
SeriesGuide Cloud에서 로그아웃함
- 로그인 오류(%s)
- 이메일 주소는 Google 로그인이 필요합니다
Google 로그인을 지원하지 않는 다른 기기에서 SeriesGuide를 사용하는 경우, 로그인할 이메일을 선택하세요.
trakt에 전송하고 있습니다.
diff --git a/app/src/main/res/values-mk/strings.xml b/app/src/main/res/values-mk/strings.xml
index 9a21ad0c34..bbd2551a9e 100644
--- a/app/src/main/res/values-mk/strings.xml
+++ b/app/src/main/res/values-mk/strings.xml
@@ -328,8 +328,6 @@
Отстранување на вашиот профил ќе бидат избришани сите емисии и филмови од SeriesGuide Cloud. Бидете сигурни да го отстраните како и од другите поврзани уреди.
Испраќање во SeriesGuide Cloud.
Одлогирај од SeriesGuide Cloud
- Error with sign-in (%s)
- Email address requires Google Sign-In
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
Испраќање на trakt.
diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml
index e77e690b6e..814caacd07 100644
--- a/app/src/main/res/values-nb/strings.xml
+++ b/app/src/main/res/values-nb/strings.xml
@@ -328,8 +328,6 @@
Fjerning av kontoen vil slette alle serier og filmer fra SeriesGuide Cloud. Husk å fjerne den fra andre tilkoblede enheter også.
Sender til SeriesGuide Cloud.
Utlogget fra SeriesGuide Cloud
- Error with sign-in (%s)
- Email address requires Google Sign-In
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
Sender til trakt.
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 6009ed9aa8..c76f925552 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -328,8 +328,6 @@
Dit zal al uw series, lijsten en films van SeriesGuide Cloud verwijderen
Verzonden naar SeriesGuide Cloud.
Afgemeld bij SeriesGuide Cloud
- Fout bij aanmelden (%s)
- E-mailadres vereist aanmelding bij Google
Kies e-mail om aan te melden als je SeriesGuide gebruikt op andere apparaten die Google-aanmelding niet ondersteunen.
Toevoegen aan trakt.
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index f0b3462bc1..41025f2d49 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -348,8 +348,6 @@
Usunięcie konta spowoduje usunięcie wszystkich seriali i filmów z chmury SeriesGuide. Upewnij się, że dane te zostały usunięte także z pozostałych podłączonych urządzeń.
Wysłanie do chmury SeriesGuide.
Wylogowano z SeriesGuide Cloud.
- Błąd podczas logowania (%s)
- Adres e-mail wymaga logowania Google
Wybierz adres e-mail do zalogowania, jeśli używasz SeriesGuide na innych urządzeniach, które nie obsługują logowania Google.
Wysyłanie do trakt.
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 1cfde644b4..9c7b102c2b 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -328,8 +328,6 @@
Isto apagará suas séries, listas e filmes do SeriesGuide Cloud
Enviando ao SeriesGuide Cloud.
Desconectado do SeriesGuide Cloud.
- Erro no login (%s)
- O endereço de e-mail requer login do Google
Escolha o e-mail para entrar se você estiver usando o SeriesGuide em outros dispositivos que não suportam o login do Google.
Enviando ao trakt.
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 3edd458988..db5a0c0afc 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -328,8 +328,6 @@
Ao remover a tua conta todas as séries e filmes serão removidos do servidor SeriesGuide Cloud. Certifica-te que removes a tua conta também noutros dispositivos.
A enviar para o SeriesGuide Cloud.
Desconectado do SeriesGuide Cloud.
- Erro ao iniciar sessão (%s)
- O endereço de e-mail requer Início de Sessão da Google
Escolhe o e-mail para te conectares se estiveres a usar o SeriesGuide em outros dispositivos que não suportem o início de sessão da Google.
A enviar para o Trakt.
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index dd880027d8..6cc9d56593 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -338,8 +338,6 @@
Ștergerea contului va rezulta în ștergerea emisiunilor și filmelor de pe SeriesGuide Cloud. Asigurați-vă că le veți șterge și de pe celelalte dispozitive.
Se trimite către SeriesGuide Cloud.
Deconectat de pe SeriesGuide Cloud.
- Error with sign-in (%s)
- Email address requires Google Sign-In
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
Trimite pe trakt.
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 256c93bd21..0a9ca63ebf 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -348,8 +348,6 @@
При удалении аккаунта будут удалены все сериалы и фильмы из SeriesGuide Cloud. Также убедитесь в том, что он удалён на других подключенных устройствах.
Отправка в облако SeriesGuide.
Вышли из SeriesGuide Cloud.
- Ошибка входа в систему (%s)
- Адрес электронной почты требует авторизации Google
Введите электронную почту для входа, если вы используете SeriesGuide на других устройствах, которые не поддерживают вход в систему Google.
Отправка в trakt.
diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml
index eb6c8c611f..a5460e12c6 100644
--- a/app/src/main/res/values-sk/strings.xml
+++ b/app/src/main/res/values-sk/strings.xml
@@ -348,8 +348,6 @@
Odstránenie účtu vymaže všetky seriály a filmy zo SeriesGuide Cloudu. Uistite sa, že ste ho odstránili aj z iných pripojených zariadení.
Odosielanie do SeriesGuide Cloud.
Ste odhlásený zo SeriesGuide Cloud.
- Chyba pri prihlasovaní (%s)
- E-mailová adresa vyžaduje Google prihlásenie
Ak používate SeriesGuide na iných zariadeniach, ktoré nepodporujú prihlásenie cez Google, vyberte e-mail na prihlásenie.
Odosiela sa na trakt.
diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml
index 45cbd1c520..75bc051646 100644
--- a/app/src/main/res/values-sr/strings.xml
+++ b/app/src/main/res/values-sr/strings.xml
@@ -338,8 +338,6 @@
Брисање вашег налога ће обрисати све серије и филмове са SeriesGuide Cloud. Побрините се да га обришете и са осталих повезаних уређаја.
Слање на SeriesGuide Cloud.
Излогован из SeriesGuide Cloud-a
- Грешка са пријавом (%s)
- Имејл захтева пријављивање на Google
Изаберите имејл за пријаву ако користите SeriesGuide на другим уређајима који не подржавају пријављивање на Google.
Послато на тракт.
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 798576bd00..5f209e3992 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -328,8 +328,6 @@
Att ta bort ditt konto kommer även att radera alla serier och filmer från SeriesGuide Cloud. Se till att ta bort det även på andra anslutna enheter.
Skickar till SeriesGuide Cloud.
Loggat ut från SeriesGuide Cloud.
- Fel med inloggning (%s)
- E-postadress kräver Google-inloggning
Välj e-post att logga in med om du använder SeriesGuide på andra enheter som inte har stöd för Google-inloggning.
Skickar till trakt.
diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml
index 141e0f91e6..319ccbae61 100644
--- a/app/src/main/res/values-ta/strings.xml
+++ b/app/src/main/res/values-ta/strings.xml
@@ -328,8 +328,6 @@
உங்கள் கணக்கு அகற்றுவது இருக்கும் அனைத்து காட்சிகள் மற்றும் திரைப்படங்கள் இருந்து நீக்க SeriesGuide மேகம். மற்ற இணைக்கப்பட்ட சாதனங்கள் நன்றாக அதை அகற்ற உறுதி.
SeriesGuide மேகம் அனுப்புகிறது.
Signed out of SeriesGuide Cloud
- Error with sign-in (%s)
- Email address requires Google Sign-In
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
Trakt அனுப்புகிறது.
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index 286f200ae6..cd87d435b9 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -318,8 +318,6 @@
การลบบัญชีของคุณจะลบซีรีส์และภาพยนตร์ทั้งหมดออกจาก SeriesGuide Cloud ยืนยันให้แน่ใจว่าคุณต้องการลบข้อมูลออกจากอุปกรณ์ที่เชื่อมต่ออยู่ด้วยเช่นกัน
กำลังส่งไปยัง SeriesGuide Cloud
ออกจากระบบ SeriesGuide Cloud แล้ว
- ข้อผิดพลาดในการเข้าสู่ระบบ (%s)
- ที่อยู่อีเมลต้องการการเข้าสู่ระบบด้วย Google
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
กำลังส่งไปยัง trakt
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 8fdfc9fa95..5e06f02ce5 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -328,8 +328,6 @@
Hesabınızı kaldırdığınızda SeriesGuide Cloud üzerindeki bütün film ve dizileriniz silinecektir. Hesabınızı diğer bağlı cihazlardan da kaldırdığınızdan emin olun.
SeriesGuide bulutuna gönder.
SeriesGuide Bulut servisinden çıkış yapıldı.
- %s ile girişte hata oluştu
- E-posta adresi, Google ile Oturum Açmayı gerektirir
SeriesGuide\'ı, Google ile oturum açmayı desteklemeyen diğer cihazlarda kullanıyorsanız, oturum açmak için e-postayı seçin.
Trakt\'a gönderiliyor.
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 4762f36ca4..5a9355a0a0 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -348,8 +348,6 @@
Видалення облікового запису призведе до видалення всіх серіалів та фільмів з хмари SeriesGuide. Переконайтесь що видалили його і на інших підключених пристроях.
Надсилання до хмари SeriesGuide.
Вихід з SeriesGuide хмари.
- Помилка входу (%s)
- Адреса електронної скриньки вимагає авторизації Google
Виберіть адресу електронної скриньки для входу, якщо ви використовуєте SeriesGuide на інших пристроях, які не підтримують вхід через обліковий запис Google.
Надсилання до trakt.
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-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 81f284847f..d98becafea 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -318,8 +318,6 @@
这将从 SeriesGuide Cloud 中删除您的节目、列表和电影
正在与 SeriesGuide Cloud 同步…
已登出 SeriesGuide Cloud
- 登录错误(%s)
- 电子邮件地址需要 Google 登录
如果您在不支持 Google 登录的其他设备上使用 SeriesGuide,请选择电子邮件登录。
正在与 Trakt 同步…
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index c2d260f0bb..f3cdf31031 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -318,8 +318,6 @@
移除你的帳戶將會刪除SeriesGuide Cloud上所有節目與電影的紀錄。請確認其他裝置上的資料也一併刪除。
發送至SeriesGuide Cloud。
已登出 SeriesGuide Cloud
- 登錄錯誤 (%s)
- 電郵地址需要 Google 登錄
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
正發送至trakt。
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index 6d0501fd87..b21e4dc838 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -1,7 +1,6 @@
-
SeriesGuide
SeriesGuide Cloud
IMDb
@@ -66,6 +65,78 @@
Community
Debug View
+
+
+
+ Email
+
+
+ Please verify %1$s to continue.
+ Send verification email
+ I\'ve verified my email
+ Manage multi-factor authentication
+ Skip for now
+ Remove
+ Verify
+
+
+ Secret key
+ I\'ve saved these codes
+ 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
+ Authenticator app
+ Unknown method
+ Enrolled on %1$s
+ Scan the QR code or enter the secret key in your authenticator app
+
+
+ Verify your identity
+ For your security, please re-enter your password to continue.
+ Account: %1$s
+ Authentication failed. Please try again.
+
+
+ Use at least one uppercase letter
+ Use at least one lowercase letter
+ Use at least one number
+ Use at least one special character
+
+
+ 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.
+ Try opening the link using the same device or browser where you started the sign-in process.
+ The action code is invalid. This can happen if the code is malformed, expired, or has already been used.
+ Confirm email to continue sign in
+ 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.
+
+
+ Additional verification required. Please complete multi-factor authentication.
+
+
+ Choose Authentication Method
+ Set Up Authenticator App
+ Verify Your Code
+ Save Your Recovery Codes
+ Select a second authentication method to secure your account
+ Scan the QR code with your authenticator app
+ 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.
+
+
+ Valid code required
+ 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.
+
- @string/sort_order_latest_first
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 04c83f0ee9..5b4fcd2648 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,5 @@
-
+
Shows
@@ -353,8 +353,6 @@
This will delete your shows, lists and movies from SeriesGuide Cloud
Sending to SeriesGuide Cloud
Signed out of SeriesGuide Cloud
- Error with sign-in (%s)
- Email address requires Google Sign-In
Choose email to sign in if you are using SeriesGuide on other devices that do not support Google sign-in.
@@ -628,4 +626,45 @@
Purchase
Rental
+
+
+ Continue
+ Back
+ By continuing, you are indicating that you accept the %1$s.
+ Creating new accounts is currently not allowed
+
+
+ Sign in with Google
+ Sign in with email
+ Continue with Google
+ Continue with email
+
+
+ Your name
+ Email
+ Password
+ Confirm Password
+ Passwords do not match
+ Required
+ Valid email address required
+ Create an account
+ This email address is already in use by an existing account. Please sign in with your existing account.
+ Show password
+
+
+ Password not strong enough
+ Use at least %1$d characters
+
+
+ Reset password
+ Check your email
+ If an account for this email address exists, an email with instructions to reset your password was sent to it.
+
+
+ Authentication Error
+ An account was already created for this email address using a different sign-in method. Sign in using the original method to add this one.
+ No Google account is available on this device. Sign in using email or add a Google account to this device.
+ An unknown error occurred
+ This password is incorrect, this account does not support signing in with a password or there is no account with this email address. Maybe reset your password.
+
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 @@
-
-
-