diff --git a/.gitignore b/.gitignore index ca0f434ff..9f98416b1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ google-services.json crashlytics-build.properties auth/src/main/res/values/com_crashlytics_export_strings.xml *.log +internal/** +lint/** diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cec75d966..2fdbdca67 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,35 +1,33 @@ // NOTE: this project uses Gradle Kotlin DSL. More common build.gradle instructions can be found in // the main README. plugins { - id("com.android.application") + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose") + id("com.google.gms.google-services") + id("kotlin-kapt") } android { - compileSdk = Config.SdkVersions.compile - namespace = "com.firebase.uidemo" + compileSdk = Config.SdkVersions.compile defaultConfig { minSdk = Config.SdkVersions.min targetSdk = Config.SdkVersions.target - versionName = Config.version versionCode = 1 - + multiDexEnabled = true resourcePrefix("fui_") vectorDrawables.useSupportLibrary = true } - defaultConfig { - multiDexEnabled = true - } - buildTypes { - named("release").configure { + release { // For the purposes of the sample, allow testing of a proguarded release build // using the debug key signingConfig = signingConfigs["debug"] - postprocessing { isRemoveUnusedCode = true isRemoveUnusedResources = true @@ -39,6 +37,16 @@ android { } } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + viewBinding = true + compose = true + } + lint { // Common lint options across all modules @@ -60,15 +68,6 @@ android { baseline = file("$rootDir/library/quality/lint-baseline.xml") } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - buildFeatures { - viewBinding = true - } } dependencies { @@ -80,23 +79,33 @@ dependencies { implementation(project(":database")) implementation(project(":storage")) + implementation(Config.Libs.Misc.glide) + kapt(Config.Libs.Misc.glideCompiler) + implementation(Config.Libs.Provider.facebook) // Needed to override Facebook implementation(Config.Libs.Androidx.cardView) implementation(Config.Libs.Androidx.customTabs) - - implementation(Config.Libs.Misc.glide) - annotationProcessor(Config.Libs.Misc.glideCompiler) - // Used for FirestorePagingActivity implementation(Config.Libs.Androidx.paging) // The following dependencies are not required to use the Firebase UI library. // They are used to make some aspects of the demo app implementation simpler for // demonstrative purposes, and you may find them useful in your own apps; YMMV. + implementation(Config.Libs.Misc.permissions) implementation(Config.Libs.Androidx.constraint) - debugImplementation(Config.Libs.Misc.leakCanary) + + val composeBom = platform("androidx.compose:compose-bom:2025.02.00") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.compose.material3:material3") + implementation("androidx.activity:activity-compose:1.10.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5") + implementation("com.github.bumptech.glide:compose:1.0.0-beta01") + debugImplementation("androidx.compose.ui:ui-tooling") + releaseImplementation("androidx.compose.ui:ui-tooling-preview") } -apply(plugin = "com.google.gms.google-services") +kapt { correctErrorTypes = true } // optional but avoids some kapt warnings \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 60ccc6600..d717ac3b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,17 +19,13 @@ tools:ignore="GoogleAppIndexingWarning,UnusedAttribute" android:usesCleartextTraffic="true"> - + - - - @@ -42,6 +38,10 @@ + { - private static final Class[] CLASSES = new Class[]{ - AuthUiActivity.class, - AnonymousUpgradeActivity.class, - FirestoreChatActivity.class, - FirestorePagingActivity.class, - RealtimeDbChatActivity.class, - FirebaseDbPagingActivity.class, - ImageActivity.class, - }; - - private static final int[] DESCRIPTION_NAMES = new int[]{ - R.string.title_auth_activity, - R.string.title_anonymous_upgrade, - R.string.title_firestore_activity, - R.string.title_firestore_paging_activity, - R.string.title_realtime_database_activity, - R.string.title_realtime_database_paging_activity, - R.string.title_storage_activity - }; - - private static final int[] DESCRIPTION_IDS = new int[]{ - R.string.desc_auth, - R.string.desc_anonymous_upgrade, - R.string.desc_firestore, - R.string.desc_firestore_paging, - R.string.desc_realtime_database, - R.string.desc_realtime_database_paging, - R.string.desc_storage - }; - - @Override - public ActivityStarterHolder onCreateViewHolder(ViewGroup parent, int viewType) { - return new ActivityStarterHolder( - LayoutInflater.from(parent.getContext()) - .inflate(R.layout.activity_chooser_item, parent, false)); - } - - @Override - public void onBindViewHolder(ActivityStarterHolder holder, int position) { - holder.bind(CLASSES[position], DESCRIPTION_NAMES[position], DESCRIPTION_IDS[position]); - } - - @Override - public int getItemCount() { - return CLASSES.length; - } - } - - private static class ActivityStarterHolder extends RecyclerView.ViewHolder - implements View.OnClickListener { - private TextView mTitle; - private TextView mDescription; - - private Class mStarterClass; - - public ActivityStarterHolder(View itemView) { - super(itemView); - mTitle = itemView.findViewById(R.id.text1); - mDescription = itemView.findViewById(R.id.text2); - } - - private void bind(Class aClass, @StringRes int name, @StringRes int description) { - mStarterClass = aClass; - - mTitle.setText(name); - mDescription.setText(description); - itemView.setOnClickListener(this); - } - - @Override - public void onClick(View v) { - itemView.getContext().startActivity(new Intent(itemView.getContext(), mStarterClass)); - } - } -} diff --git a/app/src/main/java/com/firebase/uidemo/ChooserActivity.kt b/app/src/main/java/com/firebase/uidemo/ChooserActivity.kt new file mode 100644 index 000000000..de88a1e2e --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/ChooserActivity.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.firebase.uidemo + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.AuthUI +import com.firebase.ui.auth.util.ExtraConstants +import com.firebase.uidemo.auth.AnonymousUpgradeActivity +import com.firebase.uidemo.auth.AuthUiActivity +import com.firebase.uidemo.auth.compose.AuthComposeActivity +import com.firebase.uidemo.auth.compose.ChooserScreen +import com.firebase.uidemo.database.firestore.FirestoreChatActivity +import com.firebase.uidemo.database.firestore.FirestorePagingActivity +import com.firebase.uidemo.database.realtime.FirebaseDbPagingActivity +import com.firebase.uidemo.database.realtime.RealtimeDbChatActivity +import com.firebase.uidemo.storage.ImageActivity + +class ChooserActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Deep-link handling identical to the original Java implementation + if (AuthUI.canHandleIntent(intent)) { + val authIntent = Intent(this, AuthUiActivity::class.java).apply { + putExtra(ExtraConstants.EMAIL_LINK_SIGN_IN, intent.data.toString()) + } + startActivity(authIntent) + finish() + return + } + + setContent { ChooserScreen() } + } + + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun ChooserScreen() { + val items = remember { activityItems } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text(stringResource(R.string.app_name)) } + ) + } + ) { padding -> + LazyColumn( + contentPadding = padding, + modifier = Modifier.fillMaxSize() + ) { + items(items) { entry -> ActivityRow(entry) } + } + } + } + + @Composable + private fun ActivityRow(entry: ActivityEntry) { + val ctx = LocalContext.current + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clickable { ctx.startActivity(Intent(ctx, entry.clazz)) } + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = stringResource(entry.titleRes), + style = MaterialTheme.typography.titleMedium + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(entry.descRes), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + + private data class ActivityEntry( + val clazz: Class<*>, + @StringRes val titleRes: Int, + @StringRes val descRes: Int + ) + + private val activityItems = listOf( + ActivityEntry(AuthComposeActivity::class.java, + R.string.auth_compose_title, R.string.desc_auth), + ActivityEntry(AnonymousUpgradeActivity::class.java, + R.string.title_anonymous_upgrade, R.string.desc_anonymous_upgrade), + ActivityEntry(FirestoreChatActivity::class.java, + R.string.title_firestore_activity, R.string.desc_firestore), + ActivityEntry(FirestorePagingActivity::class.java, + R.string.title_firestore_paging_activity, R.string.desc_firestore_paging), + ActivityEntry(RealtimeDbChatActivity::class.java, + R.string.title_realtime_database_activity, R.string.desc_realtime_database), + ActivityEntry(FirebaseDbPagingActivity::class.java, + R.string.title_realtime_database_paging_activity, R.string.desc_realtime_database_paging), + ActivityEntry(ImageActivity::class.java, + R.string.title_storage_activity, R.string.desc_storage) + ) +} + + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +private fun ChooserScreenPreview() { + MaterialTheme { ChooserActivity().run { ChooserScreen() } } +} \ No newline at end of file diff --git a/app/src/main/java/com/firebase/uidemo/auth/SignedInActivity.java b/app/src/main/java/com/firebase/uidemo/auth/SignedInActivity.java index 9749f5c86..abc84c6a7 100644 --- a/app/src/main/java/com/firebase/uidemo/auth/SignedInActivity.java +++ b/app/src/main/java/com/firebase/uidemo/auth/SignedInActivity.java @@ -29,8 +29,6 @@ import com.firebase.uidemo.R; import com.firebase.uidemo.databinding.SignedInLayoutBinding; import com.firebase.uidemo.storage.GlideApp; -import com.google.android.gms.tasks.OnCompleteListener; -import com.google.android.gms.tasks.Task; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.snackbar.Snackbar; import com.google.firebase.auth.EmailAuthProvider; diff --git a/app/src/main/java/com/firebase/uidemo/auth/compose/AuthComposeActivity.kt b/app/src/main/java/com/firebase/uidemo/auth/compose/AuthComposeActivity.kt new file mode 100644 index 000000000..4930de9c7 --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/auth/compose/AuthComposeActivity.kt @@ -0,0 +1,64 @@ +package com.firebase.uidemo.auth.compose + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.view.WindowCompat +import com.firebase.ui.auth.data.model.FirebaseAuthUIAuthenticationResult +import com.firebase.uidemo.auth.SignedInActivity +import com.firebase.uidemo.ui.theme.FirebaseUIDemoTheme + +class AuthComposeActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Configure system UI + window.apply { + statusBarColor = Color.White.toArgb() + navigationBarColor = Color.White.toArgb() + + WindowCompat.getInsetsController(this, decorView).apply { + isAppearanceLightStatusBars = true + isAppearanceLightNavigationBars = true + } + } + + setContent { + FirebaseUIDemoTheme { + Surface(modifier = Modifier.fillMaxSize(), color = Color.White) { + AuthScreen { result -> handleSignInResponse(result) } + } + } + } + } + + private fun handleSignInResponse(result: FirebaseAuthUIAuthenticationResult) { + when (result.resultCode) { + RESULT_OK -> { + val response = result.idpResponse + startActivity(SignedInActivity.createIntent(this, response)) + finish() + } + else -> { + val response = result.idpResponse + if (response == null) { + finish() + return + } + } + } + } + + companion object { + fun createIntent(context: Context): Intent { + return Intent(context, AuthComposeActivity::class.java) + } + } +} diff --git a/app/src/main/java/com/firebase/uidemo/auth/compose/AuthScreen.kt b/app/src/main/java/com/firebase/uidemo/auth/compose/AuthScreen.kt new file mode 100644 index 000000000..b52b6af81 --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/auth/compose/AuthScreen.kt @@ -0,0 +1,33 @@ +package com.firebase.uidemo.auth.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.firebase.ui.auth.AuthUI.IdpConfig +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.data.model.FirebaseAuthUIAuthenticationResult +import com.firebase.uidemo.R + +@Composable +fun AuthScreen(onSignInResult: (FirebaseAuthUIAuthenticationResult) -> Unit) { + val providers = + listOf( + IdpConfig.GoogleBuilder().build(), + IdpConfig.EmailBuilder().build(), + ) + + Box(modifier = Modifier.fillMaxSize().background(Color.White)) { + FirebaseAuthUI( + providers = providers, + onSignInResult = { result -> /* optional logging */ }, + signedInContent = { SignedInScreen(idpResponse = null) {} }, + theme = R.style.AppTheme, + logo = R.drawable.firebase_auth_120dp, + tosUrl = "https://www.google.com/policies/terms/", + privacyPolicyUrl = "https://www.google.com/policies/privacy/" + ) + } +} diff --git a/app/src/main/java/com/firebase/uidemo/auth/compose/ChooserComposeActivity.kt b/app/src/main/java/com/firebase/uidemo/auth/compose/ChooserComposeActivity.kt new file mode 100644 index 000000000..ba54abb8e --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/auth/compose/ChooserComposeActivity.kt @@ -0,0 +1,17 @@ +package com.firebase.uidemo.auth.compose + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.firebase.uidemo.ui.theme.FirebaseUIDemoTheme + +class ChooserComposeActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + FirebaseUIDemoTheme { + ChooserScreen() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/firebase/uidemo/auth/compose/ChooserScreen.kt b/app/src/main/java/com/firebase/uidemo/auth/compose/ChooserScreen.kt new file mode 100644 index 000000000..4c00e6731 --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/auth/compose/ChooserScreen.kt @@ -0,0 +1,98 @@ +package com.firebase.uidemo.auth.compose + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.uidemo.R +import com.firebase.uidemo.auth.AnonymousUpgradeActivity +import com.firebase.uidemo.auth.AuthUiActivity +import com.firebase.uidemo.database.firestore.FirestoreChatActivity +import com.firebase.uidemo.database.firestore.FirestorePagingActivity +import com.firebase.uidemo.database.realtime.FirebaseDbPagingActivity +import com.firebase.uidemo.database.realtime.RealtimeDbChatActivity +import com.firebase.uidemo.storage.ImageActivity +import com.firebase.uidemo.ui.theme.FirebaseUIDemoTheme + +data class DemoActivityItem(val titleRes: Int, val descRes: Int, val activityClass: Class) + +val demoActivities = listOf( + DemoActivityItem(R.string.title_auth_activity, R.string.desc_auth, AuthUiActivity::class.java), + DemoActivityItem(R.string.auth_compose_title, R.string.desc_auth, AuthComposeActivity::class.java), + DemoActivityItem(R.string.title_anonymous_upgrade, R.string.desc_anonymous_upgrade, AnonymousUpgradeActivity::class.java), + DemoActivityItem(R.string.title_firestore_activity, R.string.desc_firestore, FirestoreChatActivity::class.java), + DemoActivityItem(R.string.title_firestore_paging_activity, R.string.desc_firestore_paging, FirestorePagingActivity::class.java), + DemoActivityItem(R.string.title_realtime_database_activity, R.string.desc_realtime_database, RealtimeDbChatActivity::class.java), + DemoActivityItem(R.string.title_realtime_database_paging_activity, R.string.desc_realtime_database_paging, FirebaseDbPagingActivity::class.java), + DemoActivityItem(R.string.title_storage_activity, R.string.desc_storage, ImageActivity::class.java), +) + +@Composable +fun ChooserScreen(modifier: Modifier = Modifier) { + val context = LocalContext.current + Surface( + modifier = modifier + .fillMaxSize() + .padding(top = 40.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(demoActivities) { item -> + DemoActivityCard(item) { + context.startActivity(Intent(context, item.activityClass)) + } + } + } + } +} + +@Composable +fun DemoActivityCard(item: DemoActivityItem, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = MaterialTheme.shapes.medium + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = stringResource(id = item.titleRes), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = item.descRes), + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ChooserScreenPreview() { + FirebaseUIDemoTheme { + ChooserScreen() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/firebase/uidemo/auth/compose/SignedInScreen.kt b/app/src/main/java/com/firebase/uidemo/auth/compose/SignedInScreen.kt new file mode 100644 index 000000000..8d1be575f --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/auth/compose/SignedInScreen.kt @@ -0,0 +1,210 @@ +package com.firebase.uidemo.auth.compose + +import android.content.Context +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ExitToApp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.bumptech.glide.integration.compose.GlideImage +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.firebase.ui.auth.AuthUI +import com.firebase.ui.auth.IdpResponse +import com.firebase.uidemo.R +import com.google.firebase.auth.* +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignedInScreen( + idpResponse: IdpResponse?, + onSignedOut: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbar = remember { SnackbarHostState() } + + val firebaseUser by produceState(initialValue = FirebaseAuth.getInstance().currentUser) { + val listener = FirebaseAuth.AuthStateListener { auth -> value = auth.currentUser } + FirebaseAuth.getInstance().addAuthStateListener(listener) + awaitDispose { FirebaseAuth.getInstance().removeAuthStateListener(listener) } + } + if (firebaseUser == null) { + onSignedOut(); return + } + + var askDelete by remember { mutableStateOf(false) } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { CenterAlignedTopAppBar(title = { Text("Profile") }) }, + floatingActionButton = { + FloatingActionButton(onClick = { askDelete = true }) { + Icon(Icons.Default.Delete, contentDescription = null) + } + } + ) { padding -> + ProfileContent( + modifier = Modifier.padding(padding), + user = firebaseUser!!, + isNewUser = idpResponse?.isNewUser ?: false, + idpToken = idpResponse?.idpToken, + idpSecret = idpResponse?.idpSecret, + onSignOut = { + AuthUI.getInstance().signOut(context).addOnCompleteListener { task -> + if (task.isSuccessful) onSignedOut() + else scope.launch { + snackbar.showSnackbar( + context.getString(R.string.sign_out_failed) + ) + } + } + } + ) + } + + if (askDelete) { + AlertDialog( + onDismissRequest = { askDelete = false }, + confirmButton = { + TextButton(onClick = { + askDelete = false + AuthUI.getInstance().delete(context).addOnCompleteListener { task -> + if (task.isSuccessful) onSignedOut() + else scope.launch { + snackbar.showSnackbar( + context.getString(R.string.delete_account_failed) + ) + } + } + }) { Text("Yes, nuke it!") } + }, + dismissButton = { TextButton(onClick = { askDelete = false }) { Text("No") } }, + title = { Text("Delete account") }, + text = { Text("Are you sure you want to delete this account?") } + ) + } +} + + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun ProfileContent( + modifier: Modifier = Modifier, + user: FirebaseUser, + isNewUser: Boolean, + idpToken: String?, + idpSecret: String?, + onSignOut: () -> Unit +) { + val ctx = LocalContext.current + val providers = remember(user) { user.enabledProviderNames(ctx) } + + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + user.photoUrl?.let { url -> + GlideImage( + model = url, + contentDescription = null, + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + ) + Spacer(Modifier.height(16.dp)) + } + + InfoRow(label = "Email", value = user.email) + InfoRow(label = "Phone", value = user.phoneNumber) + InfoRow(label = "Display name", value = user.displayName) + + if (isNewUser) { + Text( + "New user", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp) + ) + } + + Spacer(Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.used_providers, providers), + style = MaterialTheme.typography.bodySmall + ) + + if (idpToken != null) { + Spacer(Modifier.height(12.dp)) + TokenBlock(label = "IDP Token", value = idpToken) + } + if (idpSecret != null) { + Spacer(Modifier.height(8.dp)) + TokenBlock(label = "IDP Secret", value = idpSecret) + } + + Spacer(Modifier.weight(1f)) + + Button( + onClick = onSignOut, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.ExitToApp, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.sign_out)) + } + } +} + +@Composable +private fun InfoRow(label: String, value: String?) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(label) + Text( + text = value.takeUnless { it.isNullOrBlank() } ?: "—", + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + } +} + +@Composable +private fun TokenBlock(label: String, value: String) { + Column(Modifier.fillMaxWidth()) { + Text(label, style = MaterialTheme.typography.labelSmall) + Text(value, style = MaterialTheme.typography.bodySmall, overflow = TextOverflow.Ellipsis) + } +} + +private fun FirebaseUser.enabledProviderNames(ctx: Context): String { + if (providerData.isEmpty()) return ctx.getString(R.string.providers_anonymous) + + val names = providerData.mapNotNull { info -> + when (info.providerId) { + GoogleAuthProvider.PROVIDER_ID -> ctx.getString(R.string.providers_google) + FacebookAuthProvider.PROVIDER_ID -> ctx.getString(R.string.providers_facebook) + TwitterAuthProvider.PROVIDER_ID -> ctx.getString(R.string.providers_twitter) + EmailAuthProvider.PROVIDER_ID -> ctx.getString(R.string.providers_email) + PhoneAuthProvider.PROVIDER_ID -> ctx.getString(R.string.providers_phone) + AuthUI.EMAIL_LINK_PROVIDER -> ctx.getString(R.string.providers_email_link) + FirebaseAuthProvider.PROVIDER_ID -> null // ignore + else -> info.providerId + } + } + return names.joinToString() +} \ No newline at end of file diff --git a/app/src/main/java/com/firebase/uidemo/ui/theme/Color.kt b/app/src/main/java/com/firebase/uidemo/ui/theme/Color.kt new file mode 100644 index 000000000..f7da48450 --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.firebase.uidemo.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/firebase/uidemo/ui/theme/Theme.kt b/app/src/main/java/com/firebase/uidemo/ui/theme/Theme.kt new file mode 100644 index 000000000..f4f6a5bbc --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.firebase.uidemo.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 +) + +@Composable +fun FirebaseUIDemoTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/firebase/uidemo/ui/theme/Type.kt b/app/src/main/java/com/firebase/uidemo/ui/theme/Type.kt new file mode 100644 index 000000000..aff83869e --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/ui/theme/Type.kt @@ -0,0 +1,31 @@ +package com.firebase.uidemo.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) \ No newline at end of file diff --git a/app/src/main/res/layout/activity_chooser.xml b/app/src/main/res/layout/activity_chooser.xml deleted file mode 100644 index d17f0fc40..000000000 --- a/app/src/main/res/layout/activity_chooser.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88d0ed1d8..4b24aaf72 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,11 +10,16 @@ Storage Image Demo Demonstrates the Firebase Auth UI flow, with customization options. - Demonstrates upgrading an anonymous account using FirebaseUI. - Demonstrates using a FirestoreRecyclerAdapter to load data from Cloud Firestore into a RecyclerView for a basic chat app. - Demonstrates using a FirestorePagingAdapter to load/infinite scroll paged data from Cloud Firestore. - Demonstrates using a FirebaseRecyclerAdapter to load data from Firebase Database into a RecyclerView for a basic chat app. - Demonstrates using a FirebaseRecyclerPagingAdapter to load/infinite scroll paged data from Firebase Realtime Database. + Demonstrates upgrading an anonymous account using + FirebaseUI. + Demonstrates using a FirestoreRecyclerAdapter to load data from + Cloud Firestore into a RecyclerView for a basic chat app. + Demonstrates using a FirestorePagingAdapter to + load/infinite scroll paged data from Cloud Firestore. + Demonstrates using a FirebaseRecyclerAdapter to load data + from Firebase Database into a RecyclerView for a basic chat app. + Demonstrates using a FirebaseRecyclerPagingAdapter + to load/infinite scroll paged data from Firebase Realtime Database. Demonstrates displaying an image from Cloud Storage using Glide. @@ -63,7 +68,8 @@ Photos Other Options - Enable Credential Manager\'s credential selector + Enable Credential Manager\'s credential + selector Allow new account creation Require first/last name with email accounts. Connect to auth emulator (localhost:9099). @@ -111,7 +117,8 @@ Send Downloaded image - This sample will read an image from local storage to upload to Cloud Storage. + This sample will read an image from local storage to upload + to Cloud Storage. No messages. Start chatting at the bottom! @@ -119,11 +126,14 @@ Signed In Anonymous authentication failed, various components of the demo will not work. - Make sure your device is online and that Anonymous Auth is configured in your Firebase project + Make sure your device is online and that Anonymous Auth is configured in your Firebase + project (https://console.firebase.google.com/project/_/authentication/providers) Add Data Say something… Reached End of List - + + Auth UI (Compose) + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 75ae25893..98fb34d6b 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,7 +1,7 @@ - - + \ No newline at end of file diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 66df263a8..5835ae2db 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -1,36 +1,40 @@ -import com.android.build.gradle.internal.dsl.TestOptions - plugins { id("com.android.library") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose") + id("kotlin-kapt") id("com.vanniktech.maven.publish") - id("org.jetbrains.kotlin.android") } android { - compileSdk = Config.SdkVersions.compile namespace = "com.firebase.ui.auth" + compileSdk = Config.SdkVersions.compile defaultConfig { minSdk = Config.SdkVersions.min - targetSdk =Config.SdkVersions.target + @Suppress("DEPRECATION") + targetSdk = Config.SdkVersions.target buildConfigField("String", "VERSION_NAME", "\"${Config.version}\"") - resourcePrefix("fui_") vectorDrawables.useSupportLibrary = true } + buildFeatures { compose = true } + buildTypes { - named("release").configure { + release { isMinifyEnabled = false consumerProguardFiles("auth-proguard.pro") } } - - compileOptions { + + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + kotlinOptions { jvmTarget = "17" } lint { // Common lint options across all modules @@ -59,42 +63,48 @@ android { baseline = file("$rootDir/library/quality/lint-baseline.xml") } - testOptions { - unitTests { - isIncludeAndroidResources = true - } - } - kotlinOptions { - jvmTarget = "17" - } + testOptions { unitTests.isIncludeAndroidResources = true } } dependencies { implementation(Config.Libs.Androidx.materialDesign) implementation(Config.Libs.Androidx.activity) - // The new activity result APIs force us to include Fragment 1.3.0 - // See https://issuetracker.google.com/issues/152554847 implementation(Config.Libs.Androidx.fragment) implementation(Config.Libs.Androidx.customTabs) implementation(Config.Libs.Androidx.constraint) - implementation("androidx.credentials:credentials:1.3.0") - implementation("androidx.credentials:credentials-play-services-auth:1.3.0") + implementation("androidx.compose.foundation:foundation-android:1.8.1") + implementation("androidx.compose.material3:material3:1.2.1") + implementation("androidx.compose.material3:material3-android:1.3.2") + implementation("androidx.compose.runtime:runtime-livedata:1.8.1") + + val composeBom = platform("androidx.compose:compose-bom:2025.02.00") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.compose.ui:ui") + implementation("androidx.activity:activity-compose:1.8.2") + debugImplementation("androidx.compose.ui:ui-tooling") + releaseImplementation("androidx.compose.ui:ui-tooling-preview") implementation(Config.Libs.Androidx.lifecycleExtensions) + kapt(Config.Libs.Androidx.lifecycleCompiler) // ← annotation processor → kapt implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + implementation("androidx.credentials:credentials:1.3.0") + implementation("androidx.credentials:credentials-play-services-auth:1.3.0") + implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1") - annotationProcessor(Config.Libs.Androidx.lifecycleCompiler) implementation(platform(Config.Libs.Firebase.bom)) api(Config.Libs.Firebase.auth) api(Config.Libs.PlayServices.auth) compileOnly(Config.Libs.Provider.facebook) - implementation(Config.Libs.Androidx.legacySupportv4) // Needed to override deps - implementation(Config.Libs.Androidx.cardView) // Needed to override Facebook + implementation(Config.Libs.Androidx.legacySupportv4) + implementation(Config.Libs.Androidx.cardView) testImplementation(Config.Libs.Test.junit) testImplementation(Config.Libs.Test.truth) @@ -102,6 +112,9 @@ dependencies { testImplementation(Config.Libs.Test.core) testImplementation(Config.Libs.Test.robolectric) testImplementation(Config.Libs.Provider.facebook) + testImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + debugImplementation(project(":internal:lintchecks")) -} +} \ No newline at end of file diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml index bb1a19204..e56a84a48 100644 --- a/auth/src/main/AndroidManifest.xml +++ b/auth/src/main/AndroidManifest.xml @@ -70,7 +70,7 @@ android:windowSoftInputMode="adjustResize" /> diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthUI.kt new file mode 100644 index 000000000..21fe026b9 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthUI.kt @@ -0,0 +1,102 @@ +package com.firebase.ui.auth.compose + +import android.app.Activity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.firebase.ui.auth.AuthUI +import com.firebase.ui.auth.AuthUI.IdpConfig +import com.firebase.ui.auth.FirebaseAuthUIActivityResultContract +import com.firebase.ui.auth.data.model.FirebaseAuthUIAuthenticationResult +import com.google.firebase.auth.FirebaseAuth + +/** + * Composable that handles Firebase Auth UI sign-in and automatically swaps to [signedInContent] + * while a user is authenticated. + * + * @param providers Identity-providers to show in Firebase UI + * @param onSignInResult Callback with the raw Firebase UI result + * @param signedInContent UI displayed when [FirebaseAuth.getInstance().currentUser] ≠ null + * @param theme Custom theme for Firebase UI (default = library default) + * @param logo Drawable resource for the logo in Firebase UI + * @param tosUrl Terms-of-service URL (optional) + * @param privacyPolicyUrl Privacy-policy URL (optional) + * @param enableCredentials Whether to enable Google Credential Manager / Smart Lock + * @param enableAnonymousUpgrade Auto-upgrade anonymous users if `true` + */ +@Composable +fun FirebaseAuthUI( + providers: List, + signedInContent: @Composable () -> Unit, + onSignInResult: (FirebaseAuthUIAuthenticationResult) -> Unit = {}, + theme: Int = AuthUI.getDefaultTheme(), + logo: Int = AuthUI.NO_LOGO, + tosUrl: String? = null, + privacyPolicyUrl: String? = null, + enableCredentials: Boolean = true, + enableAnonymousUpgrade: Boolean = false, +) { + val auth = remember { FirebaseAuth.getInstance() } + val authUI = remember { AuthUI.getInstance() } + + val firebaseUser by + produceState(initialValue = auth.currentUser, auth) { + val listener = FirebaseAuth.AuthStateListener { value = it.currentUser } + auth.addAuthStateListener(listener) + awaitDispose { auth.removeAuthStateListener(listener) } + } + + if (firebaseUser != null) { + signedInContent() + return + } + + val signInIntent = + remember( + providers, + theme, + logo, + tosUrl, + privacyPolicyUrl, + enableCredentials, + enableAnonymousUpgrade + ) { + authUI.createSignInIntentBuilder() + .setTheme(theme) + .setLogo(logo) + .setAvailableProviders(providers) + .setCredentialManagerEnabled(enableCredentials) + .apply { + if (tosUrl != null && privacyPolicyUrl != null) { + setTosAndPrivacyPolicyUrls(tosUrl, privacyPolicyUrl) + } + if (enableAnonymousUpgrade && auth.currentUser?.isAnonymous == true) { + enableAnonymousUsersAutoUpgrade() + } + } + .build() + } + + var signInAttempted by rememberSaveable { mutableStateOf(false) } + + val launcher = + rememberLauncherForActivityResult(FirebaseAuthUIActivityResultContract()) { result -> + onSignInResult(result) + + signInAttempted = result.resultCode != Activity.RESULT_OK + } + + LaunchedEffect(Unit) { + if (!signInAttempted) { + signInAttempted = true + launcher.launch(signInIntent) + } + } + + Box(Modifier.fillMaxSize(), Alignment.Center) { CircularProgressIndicator() } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/CheckEmailScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/email/CheckEmailScreen.kt new file mode 100644 index 000000000..d92e1d0a0 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/email/CheckEmailScreen.kt @@ -0,0 +1,209 @@ +package com.firebase.ui.auth.ui.email + +import android.annotation.SuppressLint +import android.text.TextUtils +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.systemBarsPadding +import com.firebase.ui.auth.R +import com.firebase.ui.auth.data.model.FlowParameters +import com.firebase.ui.auth.data.model.User +import com.firebase.ui.auth.ui.idp.TermsAndPrivacyText +import com.google.firebase.auth.EmailAuthProvider + +@SuppressLint("WrongConstant") +@Composable +fun CheckEmailScreen( + modifier: Modifier = Modifier, + flowParameters: FlowParameters, + initialEmail: String? = null, + onExistingEmailUser: (User) -> Unit, + onExistingIdpUser: (User) -> Unit, + onNewUser: (User) -> Unit, + onDeveloperFailure: (Exception) -> Unit, +) { + var email by remember { mutableStateOf(initialEmail ?: "") } + var isEmailError by remember { mutableStateOf(false) } + var emailErrorText by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current + val context = LocalContext.current + + LaunchedEffect(initialEmail) { + if (!initialEmail.isNullOrEmpty()) { + email = initialEmail + } + } + + fun validateEmail(): Boolean { + return when { + TextUtils.isEmpty(email) -> { + isEmailError = true + emailErrorText = context.getString(R.string.fui_required_field) + false + } + !android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() -> { + isEmailError = true + emailErrorText = context.getString(R.string.fui_invalid_email_address) + false + } + else -> true + } + } + + fun getEmailProvider(): String { + flowParameters.providers.forEach { config -> + if (EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD == config.providerId) { + return EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD + } + } + return EmailAuthProvider.PROVIDER_ID + } + + val signIn = { + if (validateEmail()) { + isLoading = true + val provider = getEmailProvider() + val user = User.Builder(provider, email).build() + onExistingEmailUser(user) + } + } + + val signUp = { + if (validateEmail()) { + isLoading = true + val provider = getEmailProvider() + val user = User.Builder(provider, email).build() + onNewUser(user) + } + } + + Scaffold( + modifier = modifier + .fillMaxSize() + .systemBarsPadding(), + containerColor = MaterialTheme.colorScheme.background + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isLoading) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + ) + Spacer(Modifier.height(16.dp)) + } else { + Spacer(Modifier.height(24.dp)) + } + + Text( + text = stringResource(R.string.fui_email_link_confirm_email_message), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(32.dp)) + + OutlinedTextField( + value = email, + onValueChange = { + email = it + isEmailError = false + }, + label = { Text(stringResource(R.string.fui_email_hint)) }, + isError = isEmailError, + supportingText = if (isEmailError) { + { Text(emailErrorText) } + } else null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + signIn() + } + ), + shape = MaterialTheme.shapes.medium, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedLabelColor = MaterialTheme.colorScheme.primary, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + cursorColor = MaterialTheme.colorScheme.primary, + errorBorderColor = MaterialTheme.colorScheme.error, + errorLabelColor = MaterialTheme.colorScheme.error, + errorSupportingTextColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (isEmailError) 8.dp else 0.dp) + ) + + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = signUp, + enabled = !isLoading, + modifier = Modifier + .height(48.dp) + ) { + Text(stringResource(R.string.fui_title_register_email)) + } + + Spacer(Modifier.width(8.dp)) + + Button( + onClick = signIn, + enabled = !isLoading, + modifier = Modifier + .height(48.dp) + ) { + Text(stringResource(R.string.fui_sign_in_default)) + } + } + + Spacer(Modifier.weight(1f)) + + if (flowParameters.isPrivacyPolicyUrlProvided() && + flowParameters.isTermsOfServiceUrlProvided() + ) { + TermsAndPrivacyText( + tosUrl = flowParameters.termsOfServiceUrl!!, + ppUrl = flowParameters.privacyPolicyUrl!!, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } + } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailActivity.kt b/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailActivity.kt index 08b4d979c..038c4b678 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailActivity.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailActivity.kt @@ -16,13 +16,12 @@ package com.firebase.ui.auth.ui.email import android.content.Context import android.content.Intent import android.os.Bundle -import androidx.annotation.Nullable +import androidx.activity.compose.setContent import androidx.annotation.RestrictTo -import androidx.annotation.StringRes -import androidx.core.view.ViewCompat -import androidx.fragment.app.FragmentTransaction +import androidx.compose.runtime.Composable import com.firebase.ui.auth.AuthUI import com.firebase.ui.auth.ErrorCodes +import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException import com.firebase.ui.auth.FirebaseUiException import com.firebase.ui.auth.IdpResponse import com.firebase.ui.auth.R @@ -34,15 +33,15 @@ import com.firebase.ui.auth.util.ExtraConstants import com.firebase.ui.auth.util.data.EmailLinkPersistenceManager import com.firebase.ui.auth.util.data.ProviderUtils import com.firebase.ui.auth.viewmodel.RequestCodes +import com.firebase.ui.auth.viewmodel.email.EmailProviderResponseHandler +import com.firebase.ui.auth.viewmodel.email.WelcomeBackPasswordViewModel import com.google.android.material.textfield.TextInputLayout import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import androidx.lifecycle.ViewModelProvider -import com.firebase.ui.auth.ui.email.CheckEmailFragment -import com.firebase.ui.auth.ui.email.RegisterEmailFragment -import com.firebase.ui.auth.ui.email.EmailLinkFragment -import com.firebase.ui.auth.ui.email.TroubleSigningInFragment -import com.firebase.ui.auth.ui.email.WelcomeBackPasswordPrompt +import com.firebase.ui.auth.viewmodel.email.RecoverPasswordHandler /** * Activity to control the entire email sign up flow. Plays host to {@link CheckEmailFragment} and @@ -50,13 +49,11 @@ import com.firebase.ui.auth.ui.email.WelcomeBackPasswordPrompt * WelcomeBackIdpPrompt}. */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class EmailActivity : AppCompatBase(), - CheckEmailFragment.CheckEmailListener, - RegisterEmailFragment.AnonymousUpgradeListener, - EmailLinkFragment.TroubleSigningInListener, - TroubleSigningInFragment.ResendEmailListener { +class EmailActivity : AppCompatBase(), RegisterEmailFragment.AnonymousUpgradeListener { private var emailLayout: TextInputLayout? = null + private lateinit var mHandler: EmailProviderResponseHandler + private lateinit var mPasswordHandler: WelcomeBackPasswordViewModel companion object { @JvmStatic @@ -86,6 +83,10 @@ class EmailActivity : AppCompatBase(), setContentView(R.layout.fui_activity_register_email) emailLayout = findViewById(R.id.email_layout) + mHandler = ViewModelProvider(this).get(EmailProviderResponseHandler::class.java) + mHandler.init(getFlowParams()) + mPasswordHandler = ViewModelProvider(this).get(WelcomeBackPasswordViewModel::class.java) + mPasswordHandler.init(getFlowParams()) if (savedInstanceState != null) { return @@ -95,81 +96,106 @@ class EmailActivity : AppCompatBase(), var email: String? = intent.extras?.getString(ExtraConstants.EMAIL) val responseForLinking: IdpResponse? = intent.extras?.getParcelable(ExtraConstants.IDP_RESPONSE) val user: User? = intent.extras?.getParcelable(ExtraConstants.USER) + if (email != null && responseForLinking != null) { - // Got here from WelcomeBackEmailLinkPrompt. - val emailConfig: AuthUI.IdpConfig = ProviderUtils.getConfigFromIdpsOrThrow( - getFlowParams().providers, - AuthUI.EMAIL_LINK_PROVIDER - ) - val actionCodeSettings: ActionCodeSettings? = - emailConfig.getParams().getParcelable(ExtraConstants.ACTION_CODE_SETTINGS) - if (actionCodeSettings == null) { - finishOnDeveloperError(IllegalStateException("ActionCodeSettings cannot be null for email link sign in.")) - return - } - EmailLinkPersistenceManager.getInstance().saveIdpResponseForLinking(application, responseForLinking) - val forceSameDevice: Boolean = emailConfig.getParams().getBoolean(ExtraConstants.FORCE_SAME_DEVICE) - val fragment = EmailLinkFragment.newInstance(email, actionCodeSettings, responseForLinking, forceSameDevice) - switchFragment(fragment, R.id.fragment_register_email, EmailLinkFragment.TAG) + handleEmailLinkLinking(email, responseForLinking) } else { - var emailConfig: AuthUI.IdpConfig? = ProviderUtils.getConfigFromIdps(getFlowParams().providers, EmailAuthProvider.PROVIDER_ID) - if (emailConfig == null) { - emailConfig = ProviderUtils.getConfigFromIdps(getFlowParams().providers, AuthUI.EMAIL_LINK_PROVIDER) - } - if (emailConfig == null) { - finishOnDeveloperError(IllegalStateException("No email provider configured.")) - return - } - if (emailConfig.getParams().getBoolean(ExtraConstants.ALLOW_NEW_EMAILS, true)) { - val ft: FragmentTransaction = supportFragmentManager.beginTransaction() - if (emailConfig.providerId == AuthUI.EMAIL_LINK_PROVIDER) { - if (email == null) { - finishOnDeveloperError(IllegalStateException("Email cannot be null for email link sign in.")) - return + handleNormalEmailFlow(email, user) + } + } + + private fun handleEmailLinkLinking(email: String, responseForLinking: IdpResponse) { + val emailConfig: AuthUI.IdpConfig = ProviderUtils.getConfigFromIdpsOrThrow( + getFlowParams().providers, + AuthUI.EMAIL_LINK_PROVIDER + ) + val actionCodeSettings: ActionCodeSettings? = + emailConfig.getParams().getParcelable(ExtraConstants.ACTION_CODE_SETTINGS) + if (actionCodeSettings == null) { + finishOnDeveloperError(IllegalStateException("ActionCodeSettings cannot be null for email link sign in.")) + return + } + EmailLinkPersistenceManager.getInstance().saveIdpResponseForLinking(application, responseForLinking) + val forceSameDevice: Boolean = emailConfig.getParams().getBoolean(ExtraConstants.FORCE_SAME_DEVICE) + val fragment = EmailLinkFragment.newInstance(email, actionCodeSettings, responseForLinking, forceSameDevice) + switchFragment(fragment, R.id.fragment_register_email, EmailLinkFragment.TAG) + } + + private fun handleNormalEmailFlow(email: String?, user: User?) { + var emailConfig: AuthUI.IdpConfig? = ProviderUtils.getConfigFromIdps(getFlowParams().providers, EmailAuthProvider.PROVIDER_ID) + if (emailConfig == null) { + emailConfig = ProviderUtils.getConfigFromIdps(getFlowParams().providers, AuthUI.EMAIL_LINK_PROVIDER) + } + if (emailConfig == null) { + finishOnDeveloperError(IllegalStateException("No email provider configured.")) + return + } + + if (emailConfig.getParams().getBoolean(ExtraConstants.ALLOW_NEW_EMAILS, true)) { + if (emailConfig.providerId == AuthUI.EMAIL_LINK_PROVIDER) { + if (email == null) { + finishOnDeveloperError(IllegalStateException("Email cannot be null for email link sign in.")) + return + } + showRegisterEmailLinkFragment(emailConfig, email) + } else { + if (user == null) { + // Use default email from configuration if none was provided via the intent. + val finalEmail = email ?: emailConfig.getParams().getString(ExtraConstants.DEFAULT_EMAIL) + setContent { + CheckEmailScreenContent( + flowParameters = getFlowParams(), + initialEmail = finalEmail, + onExistingEmailUser = { user -> handleExistingEmailUser(user) }, + onExistingIdpUser = { user -> handleExistingIdpUser(user) }, + onNewUser = { user -> handleNewUser(user) }, + onDeveloperFailure = { e -> handleDeveloperFailure(e) } + ) } - showRegisterEmailLinkFragment(emailConfig, email) } else { - if (user == null) { - // Use default email from configuration if none was provided via the intent. - if (email == null) { - email = emailConfig.getParams().getString(ExtraConstants.DEFAULT_EMAIL) - } - // Pass the email (which may be null if no default is configured) to the fragment. - val fragment = CheckEmailFragment.newInstance(email) - ft.replace(R.id.fragment_register_email, fragment, CheckEmailFragment.TAG) - emailLayout?.let { - val emailFieldName = getString(R.string.fui_email_field_name) - ViewCompat.setTransitionName(it, emailFieldName) - ft.addSharedElement(it, emailFieldName) - } - ft.disallowAddToBackStack().commit() - return - } - val fragment = RegisterEmailFragment.newInstance(user) - ft.replace(R.id.fragment_register_email, fragment, RegisterEmailFragment.TAG) - emailLayout?.let { - val emailFieldName = getString(R.string.fui_email_field_name) - ViewCompat.setTransitionName(it, emailFieldName) - ft.addSharedElement(it, emailFieldName) + setContent { + RegisterEmailScreen( + flowParameters = getFlowParams(), + user = user, + onRegisterSuccess = { newUser, password -> + mHandler.startSignIn( + IdpResponse.Builder(newUser).build(), + password + ) + val result = IdpResponse.Builder(newUser).build().toIntent() + setResult(RESULT_OK, result) + }, + onRegisterError = { e -> + } + ) } - ft.disallowAddToBackStack().commit() } - } else { - emailLayout?.error = getString(R.string.fui_error_email_does_not_exist) } + } else { + emailLayout?.error = getString(R.string.fui_error_email_does_not_exist) } } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == RequestCodes.WELCOME_BACK_EMAIL_FLOW || - requestCode == RequestCodes.WELCOME_BACK_IDP_FLOW - ) { - finish(resultCode, data) - } + @Composable + private fun CheckEmailScreenContent( + flowParameters: FlowParameters, + initialEmail: String?, + onExistingEmailUser: (User) -> Unit, + onExistingIdpUser: (User) -> Unit, + onNewUser: (User) -> Unit, + onDeveloperFailure: (Exception) -> Unit + ) { + CheckEmailScreen( + flowParameters = flowParameters, + initialEmail = initialEmail, + onExistingEmailUser = onExistingEmailUser, + onExistingIdpUser = onExistingIdpUser, + onNewUser = onNewUser, + onDeveloperFailure = onDeveloperFailure + ) } - override fun onExistingEmailUser(user: User) { + private fun handleExistingEmailUser(user: User) { if (user.providerId == AuthUI.EMAIL_LINK_PROVIDER) { val emailConfig: AuthUI.IdpConfig = ProviderUtils.getConfigFromIdpsOrThrow( getFlowParams().providers, @@ -182,16 +208,49 @@ class EmailActivity : AppCompatBase(), } showRegisterEmailLinkFragment(emailConfig, email) } else { - startActivityForResult( - WelcomeBackPasswordPrompt.createIntent(this, getFlowParams(), IdpResponse.Builder(user).build()), - RequestCodes.WELCOME_BACK_EMAIL_FLOW - ) - setSlideAnimation() + setContent { + WelcomeBackPasswordPrompt( + flowParameters = getFlowParams(), + email = user.email ?: "", + idpResponse = IdpResponse.Builder(user).build(), + onSignInSuccess = { + finish(RESULT_OK, IdpResponse.Builder(user).build().toIntent()) + }, + onSignInError = { exception -> + when (exception) { + is FirebaseAuthAnonymousUpgradeException -> { + finish(ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT, exception.response.toIntent()) + } + is FirebaseAuthInvalidCredentialsException -> { + // Error is already handled in the UI + } + else -> { + finish(RESULT_CANCELED, IdpResponse.getErrorIntent(exception)) + } + } + }, + onForgotPassword = { + setContent { + RecoverPasswordScreen( + flowParameters = getFlowParams(), + initialEmail = user.email, + onSuccess = { + finish(RESULT_OK, Intent()) + }, + onError = { exception -> + finish(RESULT_CANCELED, IdpResponse.getErrorIntent(exception)) + }, + viewModel = ViewModelProvider(this@EmailActivity)[RecoverPasswordHandler::class.java] + ) + } + }, + viewModel = mPasswordHandler + ) + } } } - override fun onExistingIdpUser(user: User) { - // Existing social user: direct them to sign in using their chosen provider. + private fun handleExistingIdpUser(user: User) { startActivityForResult( WelcomeBackIdpPrompt.createIntent(this, getFlowParams(), user), RequestCodes.WELCOME_BACK_IDP_FLOW @@ -199,8 +258,7 @@ class EmailActivity : AppCompatBase(), setSlideAnimation() } - override fun onNewUser(user: User) { - // New user: direct them to create an account with email/password if account creation is enabled. + private fun handleNewUser(user: User) { var emailConfig: AuthUI.IdpConfig? = ProviderUtils.getConfigFromIdps(getFlowParams().providers, EmailAuthProvider.PROVIDER_ID) if (emailConfig == null) { emailConfig = ProviderUtils.getConfigFromIdps(getFlowParams().providers, AuthUI.EMAIL_LINK_PROVIDER) @@ -210,7 +268,6 @@ class EmailActivity : AppCompatBase(), return } if (emailConfig.getParams().getBoolean(ExtraConstants.ALLOW_NEW_EMAILS, true)) { - val ft: FragmentTransaction = supportFragmentManager.beginTransaction() if (emailConfig.providerId == AuthUI.EMAIL_LINK_PROVIDER) { val email = user.email if (email == null) { @@ -219,45 +276,40 @@ class EmailActivity : AppCompatBase(), } showRegisterEmailLinkFragment(emailConfig, email) } else { - val fragment = RegisterEmailFragment.newInstance(user) - ft.replace(R.id.fragment_register_email, fragment, RegisterEmailFragment.TAG) - emailLayout?.let { - val emailFieldName = getString(R.string.fui_email_field_name) - ViewCompat.setTransitionName(it, emailFieldName) - ft.addSharedElement(it, emailFieldName) + setContent { + RegisterEmailScreen( + flowParameters = getFlowParams(), + user = user, + onRegisterSuccess = { newUser, password -> + mHandler.startSignIn( + IdpResponse.Builder(newUser).build(), + password + ) + val result = IdpResponse.Builder(newUser).build().toIntent() + setResult(RESULT_OK, result) + }, + onRegisterError = { e -> + + } + ) } - ft.disallowAddToBackStack().commit() } } else { emailLayout?.error = getString(R.string.fui_error_email_does_not_exist) } } - override fun onTroubleSigningIn(email: String) { - val troubleSigningInFragment = TroubleSigningInFragment.newInstance(email) - switchFragment(troubleSigningInFragment, R.id.fragment_register_email, TroubleSigningInFragment.TAG, true, true) - } - - override fun onClickResendEmail(email: String) { - if (supportFragmentManager.backStackEntryCount > 0) { - // We assume that to get to TroubleSigningInFragment we went through EmailLinkFragment, - // which was added to the fragment back stack. To avoid needing to pop the back stack twice, - // we preemptively pop off the last EmailLinkFragment. - supportFragmentManager.popBackStack() - } - val emailConfig: AuthUI.IdpConfig = ProviderUtils.getConfigFromIdpsOrThrow( - getFlowParams().providers, - EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD - ) - showRegisterEmailLinkFragment(emailConfig, email) - } - - override fun onSendEmailFailure(e: Exception) { + private fun handleDeveloperFailure(e: Exception) { finishOnDeveloperError(e) } - override fun onDeveloperFailure(e: Exception) { - finishOnDeveloperError(e) + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == RequestCodes.WELCOME_BACK_EMAIL_FLOW || + requestCode == RequestCodes.WELCOME_BACK_IDP_FLOW + ) { + finish(resultCode, data) + } } private fun finishOnDeveloperError(e: Exception) { @@ -268,7 +320,6 @@ class EmailActivity : AppCompatBase(), } private fun setSlideAnimation() { - // Make the next activity slide in. overridePendingTransition(R.anim.fui_slide_in_right, R.anim.fui_slide_out_left) } @@ -282,7 +333,7 @@ class EmailActivity : AppCompatBase(), switchFragment(fragment, R.id.fragment_register_email, EmailLinkFragment.TAG) } - override fun showProgress(@StringRes message: Int) { + override fun showProgress(message: Int) { throw UnsupportedOperationException("Email fragments must handle progress updates.") } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/ExtraConstants.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/ExtraConstants.java new file mode 100644 index 000000000..6f81de154 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/email/ExtraConstants.java @@ -0,0 +1,9 @@ +package com.firebase.ui.auth.ui.email; + +/** + * Constants for intent extras used in email-related activities. + */ +public class ExtraConstants { + public static final String FLOW_PARAMS = "extra_flow_params"; + public static final String IDP_RESPONSE = "extra_idp_response"; +} diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordActivity.java deleted file mode 100644 index 5d1164b5f..000000000 --- a/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordActivity.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright 2016 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.firebase.ui.auth.ui.email; - -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.os.Bundle; -import android.view.View; -import android.widget.Button; -import android.widget.EditText; -import android.widget.ProgressBar; -import android.widget.TextView; - -import com.firebase.ui.auth.R; -import com.firebase.ui.auth.data.model.FlowParameters; -import com.firebase.ui.auth.ui.AppCompatBase; -import com.firebase.ui.auth.util.ExtraConstants; -import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils; -import com.firebase.ui.auth.util.ui.ImeHelper; -import com.firebase.ui.auth.util.ui.fieldvalidators.EmailFieldValidator; -import com.firebase.ui.auth.viewmodel.ResourceObserver; -import com.firebase.ui.auth.viewmodel.email.RecoverPasswordHandler; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.textfield.TextInputLayout; -import com.google.firebase.auth.ActionCodeSettings; -import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException; -import com.google.firebase.auth.FirebaseAuthInvalidUserException; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RestrictTo; -import androidx.lifecycle.ViewModelProvider; - -/** - * Activity to initiate the "forgot password" flow by asking for the user's email. - */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -public class RecoverPasswordActivity extends AppCompatBase implements View.OnClickListener, - ImeHelper.DonePressedListener { - private RecoverPasswordHandler mHandler; - - private ProgressBar mProgressBar; - private Button mSubmitButton; - private TextInputLayout mEmailInputLayout; - private EditText mEmailEditText; - private EmailFieldValidator mEmailFieldValidator; - - public static Intent createIntent(Context context, FlowParameters params, String email) { - return createBaseIntent(context, RecoverPasswordActivity.class, params) - .putExtra(ExtraConstants.EMAIL, email); - } - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.fui_forgot_password_layout); - - mHandler = new ViewModelProvider(this).get(RecoverPasswordHandler.class); - mHandler.init(getFlowParams()); - mHandler.getOperation().observe(this, new ResourceObserver( - this, R.string.fui_progress_dialog_sending) { - @Override - protected void onSuccess(@NonNull String email) { - mEmailInputLayout.setError(null); - showEmailSentDialog(email); - } - - @Override - protected void onFailure(@NonNull Exception e) { - if (e instanceof FirebaseAuthInvalidUserException - || e instanceof FirebaseAuthInvalidCredentialsException) { - // No FirebaseUser exists with this email address, show error. - mEmailInputLayout.setError(getString(R.string.fui_error_email_does_not_exist)); - } else { - // Unknown error - mEmailInputLayout.setError(getString(R.string.fui_error_unknown)); - } - } - }); - - mProgressBar = findViewById(R.id.top_progress_bar); - mSubmitButton = findViewById(R.id.button_done); - mEmailInputLayout = findViewById(R.id.email_layout); - mEmailEditText = findViewById(R.id.email); - mEmailFieldValidator = new EmailFieldValidator(mEmailInputLayout); - - String email = getIntent().getStringExtra(ExtraConstants.EMAIL); - if (email != null) { - mEmailEditText.setText(email); - } - - ImeHelper.setImeOnDoneListener(mEmailEditText, this); - mSubmitButton.setOnClickListener(this); - - TextView footerText = findViewById(R.id.email_footer_tos_and_pp_text); - PrivacyDisclosureUtils.setupTermsOfServiceFooter(this, getFlowParams(), footerText); - } - - @Override - public void onClick(View view) { - if (view.getId() == R.id.button_done) { - onDonePressed(); - } - } - - @Override - public void onDonePressed() { - if (mEmailFieldValidator.validate(mEmailEditText.getText())) { - if (getFlowParams().passwordResetSettings != null) { - resetPassword(mEmailEditText.getText().toString(), getFlowParams().passwordResetSettings); - } - else { - resetPassword(mEmailEditText.getText().toString(), null); - } - } - } - - private void resetPassword(String email, @Nullable ActionCodeSettings passwordResetSettings) { - mHandler.startReset(email, passwordResetSettings); - } - private void showEmailSentDialog(String email) { - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.fui_title_confirm_recover_password) - .setMessage(getString(R.string.fui_confirm_recovery_body, email)) - .setOnDismissListener(dialog -> finish(RESULT_OK, new Intent())) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - - @Override - public void showProgress(int message) { - mSubmitButton.setEnabled(false); - mProgressBar.setVisibility(View.VISIBLE); - } - - @Override - public void hideProgress() { - mSubmitButton.setEnabled(true); - mProgressBar.setVisibility(View.INVISIBLE); - } -} diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordScreen.kt new file mode 100644 index 000000000..70fa9a2ee --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordScreen.kt @@ -0,0 +1,209 @@ +package com.firebase.ui.auth.ui.email + +import android.content.Intent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.livedata.observeAsState +import com.firebase.ui.auth.R +import com.firebase.ui.auth.data.model.FlowParameters +import com.firebase.ui.auth.data.model.Resource +import com.firebase.ui.auth.data.model.State +import com.firebase.ui.auth.ui.idp.TermsAndPrivacyText +import com.firebase.ui.auth.viewmodel.email.RecoverPasswordHandler +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthInvalidUserException + +@Composable +fun RecoverPasswordScreen( + modifier: Modifier = Modifier, + flowParameters: FlowParameters, + initialEmail: String? = null, + onSuccess: () -> Unit, + onError: (Exception) -> Unit, + viewModel: RecoverPasswordHandler +) { + val context = LocalContext.current + + var email by remember { mutableStateOf(initialEmail.orEmpty()) } + var isEmailError by remember { mutableStateOf(false) } + var emailErrorText by remember { mutableStateOf("") } + + var showSentDialog by remember { mutableStateOf(false) } + var sentToEmail by remember { mutableStateOf("") } + + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(flowParameters) { + viewModel.init(flowParameters) + } + + val resetState: Resource? by viewModel.operation.observeAsState(null) + + val isLoading = resetState?.state == State.LOADING + + LaunchedEffect(resetState) { + val resource = resetState ?: return@LaunchedEffect + + when (resource.state) { + State.SUCCESS -> { + resource.value?.let { address -> + sentToEmail = address + showSentDialog = true + } + } + State.FAILURE -> { + resource.exception?.let { error -> + if (error is FirebaseAuthInvalidUserException || + error is FirebaseAuthInvalidCredentialsException + ) { + isEmailError = true + emailErrorText = context.getString(R.string.fui_error_email_does_not_exist) + } else { + onError(error) + } + } + } + else -> { /* no-op */ } + } + } + + if (showSentDialog) { + BackHandler { + showSentDialog = false + onSuccess() + } + } + + if (showSentDialog) { + AlertDialog( + onDismissRequest = { + showSentDialog = false + onSuccess() + }, + confirmButton = { + TextButton(onClick = { + showSentDialog = false + onSuccess() + }) { + Text(stringResource(android.R.string.ok)) + } + }, + title = { + Text(stringResource(R.string.fui_title_confirm_recover_password)) + }, + text = { + Text(stringResource(R.string.fui_confirm_recovery_body, sentToEmail)) + } + ) + } + + Scaffold(modifier = modifier.fillMaxSize()) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(if (isLoading) 16.dp else 24.dp)) + + if (isLoading) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + ) + Spacer(Modifier.height(16.dp)) + } + + Text( + text = stringResource(R.string.fui_title_confirm_recover_password), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(32.dp)) + + OutlinedTextField( + value = email, + onValueChange = { + email = it + isEmailError = false + }, + label = { Text(stringResource(R.string.fui_email_hint)) }, + isError = isEmailError, + supportingText = { + if (isEmailError) Text(emailErrorText) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + if (email.isNotBlank()) { + viewModel.startReset( + email, + flowParameters.passwordResetSettings + ) + } + } + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (isEmailError) 8.dp else 0.dp) + ) + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = { + keyboardController?.hide() + if (email.isNotBlank()) { + viewModel.startReset( + email, + flowParameters.passwordResetSettings + ) + } + }, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text(stringResource(R.string.fui_button_text_send)) + } + + Spacer(Modifier.weight(1f)) + + if (flowParameters.isPrivacyPolicyUrlProvided() && + flowParameters.isTermsOfServiceUrlProvided() + ) { + Spacer(Modifier.height(16.dp)) + TermsAndPrivacyText( + tosUrl = flowParameters.termsOfServiceUrl!!, + ppUrl = flowParameters.privacyPolicyUrl!!, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(Modifier.height(16.dp)) + } + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/RegisterEmailScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/email/RegisterEmailScreen.kt new file mode 100644 index 000000000..7b90918b6 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/email/RegisterEmailScreen.kt @@ -0,0 +1,272 @@ +package com.firebase.ui.auth.ui.email + +import android.os.Build +import android.text.TextUtils +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +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.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.systemBarsPadding +import com.firebase.ui.auth.R +import com.firebase.ui.auth.data.model.FlowParameters +import com.firebase.ui.auth.data.model.User +import com.firebase.ui.auth.ui.idp.TermsAndPrivacyText +import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils +import com.firebase.ui.auth.util.ui.fieldvalidators.EmailFieldValidator +import com.firebase.ui.auth.util.ui.fieldvalidators.PasswordFieldValidator +import com.firebase.ui.auth.util.ui.fieldvalidators.RequiredFieldValidator +import com.google.firebase.auth.EmailAuthProvider + +@Composable +fun RegisterEmailScreen( + modifier: Modifier = Modifier, + flowParameters: FlowParameters, + user: User, + onRegisterSuccess: (User, String) -> Unit, + onRegisterError: (Exception) -> Unit, +) { + var email by remember { mutableStateOf(user.email ?: "") } + var name by remember { mutableStateOf(user.name ?: "") } + var password by remember { mutableStateOf("") } + + var isEmailError by remember { mutableStateOf(false) } + var emailErrorText by remember { mutableStateOf("") } + var isNameError by remember { mutableStateOf(false) } + var nameErrorText by remember { mutableStateOf("") } + var isPasswordError by remember { mutableStateOf(false) } + var passwordErrorText by remember { mutableStateOf("") } + + var isLoading by remember { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current + val context = LocalContext.current + + // Get configuration + val emailConfig = flowParameters.providers.find { it.providerId == EmailAuthProvider.PROVIDER_ID } + val requireName = emailConfig?.getParams()?.getBoolean("require_name", true) ?: true + val minPasswordLength = context.resources.getInteger(R.integer.fui_min_password_length) + + // Validate fields + fun validateFields(): Boolean { + var isValid = true + + // Validate email + if (!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) { + isEmailError = true + emailErrorText = context.getString(R.string.fui_invalid_email_address) + isValid = false + } + + // Validate name if required + if (requireName && name.isBlank()) { + isNameError = true + nameErrorText = context.getString(R.string.fui_missing_first_and_last_name) + isValid = false + } + + // Validate password + if (password.length < minPasswordLength) { + isPasswordError = true + passwordErrorText = context.resources.getQuantityString( + R.plurals.fui_error_weak_password, + minPasswordLength, + minPasswordLength + ) + isValid = false + } + + return isValid + } + + // Register callback + val register = { + if (validateFields()) { + isLoading = true + val newUser = User.Builder(EmailAuthProvider.PROVIDER_ID, email) + .setName(name) + .setPhotoUri(user.photoUri) + .build() + onRegisterSuccess(newUser, password) + } + } + + Scaffold( + modifier = modifier + .fillMaxSize() + .systemBarsPadding(), + containerColor = MaterialTheme.colorScheme.background + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isLoading) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + ) + Spacer(Modifier.height(16.dp)) + } else { + Spacer(Modifier.height(24.dp)) + } + + Text( + text = stringResource(R.string.fui_title_register_email), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(32.dp)) + + OutlinedTextField( + value = email, + onValueChange = { + email = it + isEmailError = false + }, + label = { Text(stringResource(R.string.fui_email_hint)) }, + isError = isEmailError, + supportingText = if (isEmailError) { + { Text(emailErrorText) } + } else null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + shape = MaterialTheme.shapes.medium, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedLabelColor = MaterialTheme.colorScheme.primary, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + cursorColor = MaterialTheme.colorScheme.primary, + errorBorderColor = MaterialTheme.colorScheme.error, + errorLabelColor = MaterialTheme.colorScheme.error, + errorSupportingTextColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (isEmailError) 8.dp else 0.dp) + ) + + Spacer(Modifier.height(16.dp)) + + if (requireName) { + OutlinedTextField( + value = name, + onValueChange = { + name = it + isNameError = false + }, + label = { Text(stringResource(R.string.fui_name_hint)) }, + isError = isNameError, + supportingText = if (isNameError) { + { Text(nameErrorText) } + } else null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + shape = MaterialTheme.shapes.medium, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedLabelColor = MaterialTheme.colorScheme.primary, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + cursorColor = MaterialTheme.colorScheme.primary, + errorBorderColor = MaterialTheme.colorScheme.error, + errorLabelColor = MaterialTheme.colorScheme.error, + errorSupportingTextColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (isNameError) 8.dp else 0.dp) + ) + + Spacer(Modifier.height(16.dp)) + } + + OutlinedTextField( + value = password, + onValueChange = { + password = it + isPasswordError = false + }, + label = { Text(stringResource(R.string.fui_password_hint)) }, + isError = isPasswordError, + supportingText = if (isPasswordError) { + { Text(passwordErrorText) } + } else null, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + register() + } + ), + shape = MaterialTheme.shapes.medium, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedLabelColor = MaterialTheme.colorScheme.primary, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + cursorColor = MaterialTheme.colorScheme.primary, + errorBorderColor = MaterialTheme.colorScheme.error, + errorLabelColor = MaterialTheme.colorScheme.error, + errorSupportingTextColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (isPasswordError) 8.dp else 0.dp) + ) + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = register, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text(stringResource(R.string.fui_title_register_email)) + } + + Spacer(Modifier.weight(1f)) + + if (flowParameters.isPrivacyPolicyUrlProvided() && + flowParameters.isTermsOfServiceUrlProvided() + ) { + TermsAndPrivacyText( + tosUrl = flowParameters.termsOfServiceUrl!!, + ppUrl = flowParameters.privacyPolicyUrl!!, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } + } + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordActivity.kt b/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordActivity.kt new file mode 100644 index 000000000..bad1ae6ef --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordActivity.kt @@ -0,0 +1,67 @@ +// src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordActivity.kt +package com.firebase.ui.auth.ui.email + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.ui.Modifier +import com.firebase.ui.auth.IdpResponse +import com.firebase.ui.auth.data.model.FlowParameters +import com.firebase.ui.auth.viewmodel.email.WelcomeBackPasswordViewModel +import com.firebase.ui.auth.viewmodel.email.WelcomeBackPasswordViewModelFactory +import com.firebase.ui.auth.ui.email.WelcomeBackPasswordPrompt + +class WelcomeBackPasswordActivity : ComponentActivity() { + companion object { + private const val EXTRA_FLOW_PARAMS = "extra_flow_params" + private const val EXTRA_IDP_RESPONSE = "extra_idp_response" + + @JvmStatic + fun createIntent( + context: Context, + flowParams: FlowParameters, + response: IdpResponse + ): Intent = Intent(context, WelcomeBackPasswordActivity::class.java).apply { + putExtra(EXTRA_FLOW_PARAMS, flowParams) + putExtra(EXTRA_IDP_RESPONSE, response) + } + } + + private lateinit var viewModel: WelcomeBackPasswordViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val flowParams = intent + .getParcelableExtra(EXTRA_FLOW_PARAMS) + ?: error("Missing flow parameters") + val response = intent + .getParcelableExtra(EXTRA_IDP_RESPONSE) + ?: error("Missing IdpResponse") + + viewModel = WelcomeBackPasswordViewModelFactory(application, flowParams) + .create(WelcomeBackPasswordViewModel::class.java) + + // This is the ComposeActivity.setContent extension: + setContent { + WelcomeBackPasswordPrompt( + flowParameters = flowParams, + email = response.email!!, + idpResponse = response, + onSignInSuccess = { + setResult(RESULT_OK, response.toIntent()) + finish() + }, + onSignInError = { e -> + setResult(RESULT_CANCELED, IdpResponse.getErrorIntent(e)) + finish() + }, + onForgotPassword = { + }, + viewModel = viewModel + ) + } + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPrompt.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPrompt.java deleted file mode 100644 index 7528152bd..000000000 --- a/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPrompt.java +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright 2016 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.firebase.ui.auth.ui.email; - -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.view.View; -import android.view.WindowManager; -import android.widget.Button; -import android.widget.EditText; -import android.widget.ProgressBar; -import android.widget.TextView; - -import com.firebase.ui.auth.ErrorCodes; -import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException; -import com.firebase.ui.auth.FirebaseUiException; -import com.firebase.ui.auth.IdpResponse; -import com.firebase.ui.auth.R; -import com.firebase.ui.auth.data.model.FlowParameters; -import com.firebase.ui.auth.ui.AppCompatBase; -import com.firebase.ui.auth.util.ExtraConstants; -import com.firebase.ui.auth.util.FirebaseAuthError; -import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils; -import com.firebase.ui.auth.util.data.ProviderUtils; -import com.firebase.ui.auth.util.ui.ImeHelper; -import com.firebase.ui.auth.util.ui.TextHelper; -import com.firebase.ui.auth.viewmodel.ResourceObserver; -import com.firebase.ui.auth.viewmodel.email.WelcomeBackPasswordHandler; -import com.google.android.material.textfield.TextInputLayout; -import com.google.firebase.auth.AuthCredential; -import com.google.firebase.auth.FirebaseAuthException; -import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RestrictTo; -import androidx.annotation.StringRes; -import androidx.lifecycle.ViewModelProvider; - -/** - * Activity to link a pre-existing email/password account to a new IDP sign-in by confirming the - * password before initiating a link. - */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -public class WelcomeBackPasswordPrompt extends AppCompatBase - implements View.OnClickListener, ImeHelper.DonePressedListener { - private IdpResponse mIdpResponse; - private WelcomeBackPasswordHandler mHandler; - - private Button mDoneButton; - private ProgressBar mProgressBar; - private TextInputLayout mPasswordLayout; - private EditText mPasswordField; - - public static Intent createIntent( - Context context, FlowParameters flowParams, IdpResponse response) { - return createBaseIntent(context, WelcomeBackPasswordPrompt.class, flowParams) - .putExtra(ExtraConstants.IDP_RESPONSE, response); - } - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.fui_welcome_back_password_prompt_layout); - - // Show keyboard - getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); - - mIdpResponse = IdpResponse.fromResultIntent(getIntent()); - String email = mIdpResponse.getEmail(); - - mDoneButton = findViewById(R.id.button_done); - mProgressBar = findViewById(R.id.top_progress_bar); - mPasswordLayout = findViewById(R.id.password_layout); - mPasswordField = findViewById(R.id.password); - - ImeHelper.setImeOnDoneListener(mPasswordField, this); - - // Create welcome back text with email bolded. - String bodyText = - getString(R.string.fui_welcome_back_password_prompt_body, email); - - SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(bodyText); - TextHelper.boldAllOccurencesOfText(spannableStringBuilder, bodyText, email); - - TextView bodyTextView = findViewById(R.id.welcome_back_password_body); - bodyTextView.setText(spannableStringBuilder); - - // Click listeners - mDoneButton.setOnClickListener(this); - findViewById(R.id.trouble_signing_in).setOnClickListener(this); - - // Initialize ViewModel with arguments - mHandler = new ViewModelProvider(this).get(WelcomeBackPasswordHandler.class); - mHandler.init(getFlowParams()); - - // Observe the state of the main auth operation - mHandler.getOperation().observe(this, new ResourceObserver( - this, R.string.fui_progress_dialog_signing_in) { - @Override - protected void onSuccess(@NonNull IdpResponse response) { - startSaveCredentials( - mHandler.getCurrentUser(), response, mHandler.getPendingPassword()); - } - - @Override - protected void onFailure(@NonNull Exception e) { - if (e instanceof FirebaseAuthAnonymousUpgradeException) { - IdpResponse response = ((FirebaseAuthAnonymousUpgradeException) e).getResponse(); - finish(ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT, response.toIntent()); - return; - } - - if (e instanceof FirebaseAuthException) { - FirebaseAuthException authEx = (FirebaseAuthException) e; - FirebaseAuthError error = FirebaseAuthError.fromException(authEx); - if (error == FirebaseAuthError.ERROR_USER_DISABLED) { - IdpResponse resp = IdpResponse.from( - new FirebaseUiException(ErrorCodes.ERROR_USER_DISABLED)); - finish(RESULT_CANCELED, resp.toIntent()); - return; - } - } - - mPasswordLayout.setError(getString(getErrorMessage(e))); - } - }); - - TextView footerText = findViewById(R.id.email_footer_tos_and_pp_text); - PrivacyDisclosureUtils.setupTermsOfServiceFooter(this, getFlowParams(), footerText); - } - - @StringRes - private int getErrorMessage(Exception exception) { - if (exception instanceof FirebaseAuthInvalidCredentialsException) { - return R.string.fui_error_invalid_password; - } - - return R.string.fui_error_unknown; - } - - private void onForgotPasswordClicked() { - startActivity(RecoverPasswordActivity.createIntent( - this, - getFlowParams(), - mIdpResponse.getEmail())); - } - - @Override - public void onDonePressed() { - validateAndSignIn(); - } - - private void validateAndSignIn() { - validateAndSignIn(mPasswordField.getText().toString()); - } - - private void validateAndSignIn(String password) { - // Check for null or empty password - if (TextUtils.isEmpty(password)) { - mPasswordLayout.setError(getString(R.string.fui_error_invalid_password)); - return; - } else { - mPasswordLayout.setError(null); - } - - AuthCredential authCredential = ProviderUtils.getAuthCredential(mIdpResponse); - mHandler.startSignIn(mIdpResponse.getEmail(), password, mIdpResponse, authCredential); - } - - @Override - public void onClick(View view) { - final int id = view.getId(); - if (id == R.id.button_done) { - validateAndSignIn(); - } else if (id == R.id.trouble_signing_in) { - onForgotPasswordClicked(); - } - } - - @Override - public void showProgress(int message) { - mDoneButton.setEnabled(false); - mProgressBar.setVisibility(View.VISIBLE); - } - - @Override - public void hideProgress() { - mDoneButton.setEnabled(true); - mProgressBar.setVisibility(View.INVISIBLE); - } -} diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPrompt.kt b/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPrompt.kt new file mode 100644 index 000000000..712d34a28 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPrompt.kt @@ -0,0 +1,239 @@ +package com.firebase.ui.auth.ui.email + +import android.os.Bundle +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +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.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.systemBarsPadding +import com.firebase.ui.auth.R +import com.firebase.ui.auth.data.model.FlowParameters +import com.firebase.ui.auth.data.model.Resource +import com.firebase.ui.auth.data.model.State +import com.firebase.ui.auth.ui.idp.TermsAndPrivacyText +import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils +import com.firebase.ui.auth.viewmodel.email.WelcomeBackPasswordViewModel +import com.google.firebase.auth.FirebaseAuthException +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException + +private fun validateAndSignIn( + password: String, + context: android.content.Context, + viewModel: WelcomeBackPasswordViewModel, + email: String, + idpResponse: com.firebase.ui.auth.IdpResponse, + onError: (String) -> Unit +) { + if (password.isBlank()) { + onError(context.getString(R.string.fui_error_invalid_password)) + return + } + + viewModel.signIn(email, password, idpResponse) +} + +@Composable +fun WelcomeBackPasswordPrompt( + modifier: Modifier = Modifier, + flowParameters: FlowParameters, + email: String, + idpResponse: com.firebase.ui.auth.IdpResponse, + onSignInSuccess: () -> Unit, + onSignInError: (Exception) -> Unit, + onForgotPassword: () -> Unit, + viewModel: WelcomeBackPasswordViewModel +) { + var password by remember { mutableStateOf("") } + var isPasswordError by remember { mutableStateOf(false) } + var passwordErrorText by remember { mutableStateOf("") } + + val keyboardController = LocalSoftwareKeyboardController.current + val context = LocalContext.current + + LaunchedEffect(flowParameters) { + viewModel.init(flowParameters) + } + + val signInState by viewModel.signInState.collectAsState() + val isLoading = signInState?.getState() == State.LOADING + + LaunchedEffect(signInState) { + when (signInState?.getState()) { + State.SUCCESS -> { + onSignInSuccess() + } + State.FAILURE -> { + signInState?.getException()?.let { error -> + when (error) { + is FirebaseAuthInvalidCredentialsException -> { + isPasswordError = true + passwordErrorText = context.getString(R.string.fui_error_invalid_password) + } + else -> { + onSignInError(error) + } + } + } + } + else -> {} + } + } + + val welcomeText = buildAnnotatedString { + val baseText = context.getString(R.string.fui_welcome_back_password_prompt_body, email) + val emailIndex = baseText.indexOf(email) + append(baseText.substring(0, emailIndex)) + withStyle(SpanStyle(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)) { + append(email) + } + append(baseText.substring(emailIndex + email.length)) + } + + Scaffold( + modifier = modifier + .fillMaxSize() + .systemBarsPadding(), + containerColor = MaterialTheme.colorScheme.background + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isLoading) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + ) + Spacer(Modifier.height(16.dp)) + } else { + Spacer(Modifier.height(24.dp)) + } + + Text( + text = welcomeText, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(32.dp)) + + OutlinedTextField( + value = password, + onValueChange = { + password = it + isPasswordError = false + }, + label = { Text(stringResource(R.string.fui_password_hint)) }, + isError = isPasswordError, + supportingText = if (isPasswordError) { + { Text(passwordErrorText) } + } else null, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + validateAndSignIn( + password = password, + context = context, + viewModel = viewModel, + email = email, + idpResponse = idpResponse, + onError = { error -> + isPasswordError = true + passwordErrorText = error + } + ) + } + ), + shape = MaterialTheme.shapes.medium, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedLabelColor = MaterialTheme.colorScheme.primary, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + cursorColor = MaterialTheme.colorScheme.primary, + errorBorderColor = MaterialTheme.colorScheme.error, + errorLabelColor = MaterialTheme.colorScheme.error, + errorSupportingTextColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (isPasswordError) 8.dp else 0.dp) + ) + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = { + validateAndSignIn( + password = password, + context = context, + viewModel = viewModel, + email = email, + idpResponse = idpResponse, + onError = { error -> + isPasswordError = true + passwordErrorText = error + } + ) + }, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text(stringResource(R.string.fui_sign_in_default)) + } + + Spacer(Modifier.height(16.dp)) + + TextButton( + onClick = onForgotPassword, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.fui_trouble_signing_in)) + } + + Spacer(Modifier.weight(1f)) + + if (flowParameters.isPrivacyPolicyUrlProvided() && + flowParameters.isTermsOfServiceUrlProvided() + ) { + TermsAndPrivacyText( + tosUrl = flowParameters.termsOfServiceUrl!!, + ppUrl = flowParameters.privacyPolicyUrl!!, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } + } + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivity.kt b/auth/src/main/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivity.kt index f9df9d25f..00eb75592 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivity.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivity.kt @@ -1,45 +1,35 @@ -/* - * Copyright 2016 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ - package com.firebase.ui.auth.ui.idp -import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Bundle import android.text.TextUtils import android.util.Log -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.ProgressBar -import android.widget.TextView import android.widget.Toast -import androidx.activity.result.ActivityResult -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintSet -import androidx.core.view.isVisible +import androidx.activity.compose.setContent +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope -import com.firebase.ui.auth.AuthMethodPickerLayout import com.firebase.ui.auth.AuthUI import com.firebase.ui.auth.AuthUI.IdpConfig import com.firebase.ui.auth.ErrorCodes -import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException -import com.firebase.ui.auth.FirebaseUiException import com.firebase.ui.auth.IdpResponse import com.firebase.ui.auth.KickoffActivity import com.firebase.ui.auth.R @@ -47,445 +37,462 @@ import com.firebase.ui.auth.data.model.FlowParameters import com.firebase.ui.auth.data.model.Resource import com.firebase.ui.auth.data.model.User import com.firebase.ui.auth.data.model.UserCancellationException -import com.firebase.ui.auth.data.remote.AnonymousSignInHandler -import com.firebase.ui.auth.data.remote.EmailSignInHandler -import com.firebase.ui.auth.data.remote.FacebookSignInHandler -import com.firebase.ui.auth.data.remote.GenericIdpSignInHandler -import com.firebase.ui.auth.data.remote.GoogleSignInHandler -import com.firebase.ui.auth.data.remote.PhoneSignInHandler +import com.firebase.ui.auth.data.remote.* import com.firebase.ui.auth.ui.AppCompatBase import com.firebase.ui.auth.util.ExtraConstants -import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils import com.firebase.ui.auth.util.data.ProviderUtils import com.firebase.ui.auth.viewmodel.ProviderSignInBase import com.firebase.ui.auth.viewmodel.ResourceObserver import com.firebase.ui.auth.viewmodel.idp.SocialProviderResponseHandler -import com.google.android.gms.auth.api.identity.BeginSignInRequest import com.google.android.gms.auth.api.identity.Identity -import com.google.android.gms.auth.api.identity.SignInCredential -import com.google.android.gms.common.api.ApiException -import com.google.android.material.snackbar.Snackbar -import com.google.firebase.auth.EmailAuthProvider -import com.google.firebase.auth.FacebookAuthProvider -import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException -import com.google.firebase.auth.FirebaseAuthInvalidUserException -import com.google.firebase.auth.GoogleAuthProvider -import com.google.firebase.auth.PhoneAuthProvider -import kotlinx.coroutines.launch - -// Imports for the new Credential Manager types (adjust these to match your library) +import com.google.firebase.auth.* import androidx.credentials.Credential import androidx.credentials.CredentialManager -import androidx.credentials.CustomCredential import androidx.credentials.GetCredentialRequest import androidx.credentials.GetPasswordOption +import androidx.credentials.CustomCredential import androidx.credentials.PasswordCredential -import androidx.credentials.PublicKeyCredential import androidx.credentials.exceptions.GetCredentialException - -import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException -import com.google.firebase.auth.GoogleAuthCredential -import com.firebase.ui.auth.util.ExtraConstants.GENERIC_OAUTH_BUTTON_ID -import com.firebase.ui.auth.util.ExtraConstants.GENERIC_OAUTH_PROVIDER_ID -import com.firebase.ui.auth.AuthUI.Companion.EMAIL_LINK_PROVIDER +import kotlinx.coroutines.launch +import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException +import com.firebase.ui.auth.FirebaseUiException +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign @androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) class AuthMethodPickerActivity : AppCompatBase() { - private lateinit var mHandler: SocialProviderResponseHandler - private val mProviders: MutableList> = mutableListOf() - - private var mProgressBar: ProgressBar? = null - private var mProviderHolder: ViewGroup? = null - - private var customLayout: AuthMethodPickerLayout? = null - - // For demonstration, assume that CredentialManager provides a create() method. - private val credentialManager by lazy { - // Replace with your actual CredentialManager instance creation. - CredentialManager.create(this) - } + private lateinit var handler: SocialProviderResponseHandler + private val providers = mutableListOf>() + private var showProgress by mutableStateOf(false) + private val credentialManager by lazy { CredentialManager.create(this) } companion object { private const val TAG = "AuthMethodPickerActivity" - - @JvmStatic - fun createIntent(context: Context, flowParams: FlowParameters): Intent { - return createBaseIntent(context, AuthMethodPickerActivity::class.java, flowParams) - } + @JvmStatic fun createIntent(ctx: Context, params: FlowParameters) = + createBaseIntent(ctx, AuthMethodPickerActivity::class.java, params) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) val params = flowParams - customLayout = params.authMethodPickerLayout - - mHandler = ViewModelProvider(this).get(SocialProviderResponseHandler::class.java) - mHandler.init(params) - - if (customLayout != null) { - setContentView(customLayout!!.mainLayout) - populateIdpListCustomLayout(params.providers) - } else { - setContentView(R.layout.fui_auth_method_picker_layout) - mProgressBar = findViewById(R.id.top_progress_bar) - mProviderHolder = findViewById(R.id.btn_holder) - populateIdpList(params.providers) - - val logoId = params.logoId - if (logoId == AuthUI.NO_LOGO) { - findViewById(R.id.logo).visibility = View.GONE - - val layout = findViewById(R.id.root) - val constraints = ConstraintSet() - constraints.clone(layout) - constraints.setHorizontalBias(R.id.container, 0.5f) - constraints.setVerticalBias(R.id.container, 0.5f) - constraints.applyTo(layout) - } else { - val logo = findViewById(R.id.logo) - logo.setImageResource(logoId) - } - } - - val tosAndPpConfigured = flowParams.isPrivacyPolicyUrlProvided() && - flowParams.isTermsOfServiceUrlProvided() - - val termsTextId = if (customLayout == null) { - R.id.main_tos_and_pp - } else { - customLayout!!.tosPpView + handler = ViewModelProvider(this)[SocialProviderResponseHandler::class.java].apply { + init(params) } + observeSocialHandler() + + setContent { + Surface(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize()) { + Column( + Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + Box( + Modifier + .fillMaxWidth() + .height(120.dp), + contentAlignment = Alignment.Center + ) { + if (flowParams.logoId != AuthUI.NO_LOGO) { + Image( + painter = painterResource(flowParams.logoId), + contentDescription = stringResource(R.string.fui_accessibility_logo), + modifier = Modifier.size(100.dp) + ) + } + } - if (termsTextId >= 0) { - val termsText = findViewById(termsTextId) - if (!tosAndPpConfigured) { - termsText.visibility = View.GONE - } else { - PrivacyDisclosureUtils.setupTermsOfServiceAndPrivacyPolicyText(this, flowParams, termsText) - } - } - - // Observe the social provider response handler. - mHandler.operation.observe(this, object : ResourceObserver(this, R.string.fui_progress_dialog_signing_in) { - override fun onSuccess(response: IdpResponse) { - startSaveCredentials(mHandler.currentUser, response, null) - } - - override fun onFailure(e: Exception) { - when (e) { - is UserCancellationException -> { - // User pressed back – no error. - } - is FirebaseAuthAnonymousUpgradeException -> { - finish(ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT, e.response.toIntent()) - } - is FirebaseUiException -> { - finish(RESULT_CANCELED, IdpResponse.from(e).toIntent()) - } - else -> { - val text = getString(R.string.fui_error_unknown) - Toast.makeText(this@AuthMethodPickerActivity, text, Toast.LENGTH_SHORT).show() - } - } - } - }) - - // Attempt sign in using the new Credential Manager API. - attemptCredentialSignIn() - } - - /** - * Attempts to sign in automatically using the Credential Manager API. - */ - private fun attemptCredentialSignIn() { - val args = flowParams - val supportPasswords = ProviderUtils.getConfigFromIdps(args.providers, EmailAuthProvider.PROVIDER_ID) != null - val accountTypes = getCredentialAccountTypes() - val willRequestCredentials = supportPasswords || accountTypes.isNotEmpty() - - if (args.enableCredentials && willRequestCredentials) { - // Build the new Credential Manager request. - val getPasswordOption = GetPasswordOption() - val googleIdOption = GetGoogleIdOption.Builder() - .setFilterByAuthorizedAccounts(true) - .setServerClientId(getString(R.string.default_web_client_id)) - .build() - val request = GetCredentialRequest(listOf(getPasswordOption, googleIdOption)) - - lifecycleScope.launch { - try { - val result = credentialManager.getCredential( - context = this@AuthMethodPickerActivity, - request = request - ) - // Handle the returned credential. - handleCredentialManagerResult(result.credential) - } catch (e: GetCredentialException) { - handleCredentialManagerFailure(e) - // Fallback: show the auth method picker. - showAuthMethodPicker() - } - } - } else { - showAuthMethodPicker() - } - } + Spacer(Modifier.weight(1f)) + + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + flowParams.providers.forEach { cfg -> + ProviderButton(cfg) { launchProviderFlow(cfg) } + Spacer(Modifier.height(12.dp)) + } + } - /** - * Handles the credential returned from the Credential Manager. - */ - private fun handleCredentialManagerResult(credential: Credential) { - when (credential) { - is PasswordCredential -> { - val username = credential.id - val password = credential.password - val response = IdpResponse.Builder( - User.Builder(EmailAuthProvider.PROVIDER_ID, username).build() - ).build() - KickoffActivity.mKickstarter.setResult(Resource.forLoading()) - auth.signInWithEmailAndPassword(username, password) - .addOnSuccessListener { authResult -> - KickoffActivity.mKickstarter.handleSuccess(response, authResult) - finish() - } - .addOnFailureListener { e -> - if (e is FirebaseAuthInvalidUserException || - e is FirebaseAuthInvalidCredentialsException) { - // Sign out via the new API. - Identity.getSignInClient(application).signOut() + if (flowParams.isPrivacyPolicyUrlProvided() && + flowParams.isTermsOfServiceUrlProvided() + ) { + TermsAndPrivacyText( + tosUrl = flowParams.termsOfServiceUrl!!, + ppUrl = flowParams.privacyPolicyUrl!!, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) } } - } - is CustomCredential -> { - if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { - try { - val googleIdTokenCredential = GoogleIdTokenCredential - .createFrom(credential.data) - auth.signInWithCredential(GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null)) - .addOnSuccessListener { authResult -> - val response = IdpResponse.Builder( - User.Builder(GoogleAuthProvider.PROVIDER_ID, googleIdTokenCredential.data.getString("email")).build(), - ).setToken(googleIdTokenCredential.idToken).build() - KickoffActivity.mKickstarter.handleSuccess(response, authResult) - finish() - } - .addOnFailureListener { e -> - Log.e(TAG, "Failed to sign in with Google ID token", e) - } - } catch (e: GoogleIdTokenParsingException) { - Log.e(TAG, "Received an invalid google id token response", e) + + if (showProgress) { + LinearProgressIndicator( + Modifier + .fillMaxWidth() + .height(4.dp) + .align(Alignment.TopCenter) + ) } - } else { - // Catch any unrecognized custom credential type here. - Log.e(TAG, "Unexpected type of credential") } } - else -> { - Log.e(TAG, "Unexpected type of credential") - } } - } - - /** - * Example helper to extract a Google ID token from a PublicKeyCredential. - * In your implementation you may need to parse the JSON response accordingly. - */ - private fun extractGoogleIdToken(credential: PublicKeyCredential): String? { - // TODO: Extract and return the Google ID token from credential.authenticationResponseJson. - // For demonstration, we assume that authenticationResponseJson is the token. - return credential.authenticationResponseJson - } - - private fun handleCredentialManagerFailure(e: GetCredentialException) { - Log.e(TAG, "Credential Manager sign in failed", e) - } - /** - * Returns the account types to pass to the credential manager. - */ - private fun getCredentialAccountTypes(): List { - val accounts = mutableListOf() - for (idpConfig in flowParams.providers) { - if (idpConfig.providerId == GoogleAuthProvider.PROVIDER_ID) { - accounts.add(ProviderUtils.providerIdToAccountType(idpConfig.providerId)) - } - } - return accounts - } - - /** - * Fallback – show the auth method picker UI. - */ - private fun showAuthMethodPicker() { - hideProgress() + attemptCredentialSignIn() } - private fun populateIdpList(providerConfigs: List) { - // Clear any previous providers. - mProviders.clear() - for (idpConfig in providerConfigs) { - val buttonLayout = when (idpConfig.providerId) { - GoogleAuthProvider.PROVIDER_ID -> R.layout.fui_idp_button_google - FacebookAuthProvider.PROVIDER_ID -> R.layout.fui_idp_button_facebook - EMAIL_LINK_PROVIDER, EmailAuthProvider.PROVIDER_ID -> R.layout.fui_provider_button_email - PhoneAuthProvider.PROVIDER_ID -> R.layout.fui_provider_button_phone - AuthUI.ANONYMOUS_PROVIDER -> R.layout.fui_provider_button_anonymous - else -> { - if (!TextUtils.isEmpty(idpConfig.getParams().getString(GENERIC_OAUTH_PROVIDER_ID))) { - idpConfig.getParams().getInt(GENERIC_OAUTH_BUTTON_ID) - } else { - throw IllegalStateException("Unknown provider: ${idpConfig.providerId}") + override fun showProgress(message: Int) { showProgress = true } + override fun hideProgress() { showProgress = false } + + private fun observeSocialHandler() { + handler.operation.observe( + this, + object : ResourceObserver(this, R.string.fui_progress_dialog_signing_in) { + override fun onSuccess(response: IdpResponse) = + startSaveCredentials(handler.currentUser, response, null) + + override fun onFailure(e: Exception) { + hideProgress() + + when (e) { + is FirebaseAuthAnonymousUpgradeException -> + finish(ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT, e.response.toIntent()) + is FirebaseUiException -> + finish(RESULT_CANCELED, IdpResponse.from(e).toIntent()) + is UserCancellationException -> + Unit + else -> + Toast.makeText( + this@AuthMethodPickerActivity, + getString(R.string.fui_error_unknown), + Toast.LENGTH_SHORT + ).show() } - } - } - val loginButton = layoutInflater.inflate(buttonLayout, mProviderHolder, false) - handleSignInOperation(idpConfig, loginButton) - mProviderHolder?.addView(loginButton) - } + } } + ) } - private fun populateIdpListCustomLayout(providerConfigs: List) { - val providerButtonIds = customLayout?.providersButton ?: return - for (idpConfig in providerConfigs) { - val providerId = providerOrEmailLinkProvider(idpConfig.providerId) - val buttonResId = providerButtonIds[providerId] - ?: throw IllegalStateException("No button found for auth provider: ${idpConfig.providerId}") - val loginButton = findViewById(buttonResId) - handleSignInOperation(idpConfig, loginButton) - } - // Hide custom layout buttons that don't have an associated provider. - for ((providerBtnId, resId) in providerButtonIds) { - if (providerBtnId == null) continue - var hasProvider = false - for (idpConfig in providerConfigs) { - if (providerOrEmailLinkProvider(idpConfig.providerId) == providerBtnId) { - hasProvider = true - break - } - } - if (!hasProvider) { - findViewById(resId)?.visibility = View.GONE - } + private fun launchProviderFlow(cfg: IdpConfig) { + if (isOffline()) { + Toast.makeText(this, getString(R.string.fui_no_internet), Toast.LENGTH_SHORT).show() + return } + getProviderForConfig(cfg).also { providers += it } + .startSignIn(auth, this, cfg.providerId) + showProgress = true } - private fun providerOrEmailLinkProvider(providerId: String): String { - return if (providerId == EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD) { - EmailAuthProvider.PROVIDER_ID - } else providerId - } - - private fun handleSignInOperation(idpConfig: IdpConfig, view: View) { - val providerId = idpConfig.providerId + private fun getProviderForConfig(idp: IdpConfig): ProviderSignInBase<*> { val authUI = getAuthUI() - val viewModelProvider = ViewModelProvider(this) - val provider: ProviderSignInBase<*> = when (providerId) { - EMAIL_LINK_PROVIDER, EmailAuthProvider.PROVIDER_ID -> - viewModelProvider.get(EmailSignInHandler::class.java).initWith(null) + val vm = ViewModelProvider(this) + val pid = idp.providerId + + val provider = when (pid) { + AuthUI.EMAIL_LINK_PROVIDER, + EmailAuthProvider.PROVIDER_ID -> + vm.get(EmailSignInHandler::class.java).initWith(null) PhoneAuthProvider.PROVIDER_ID -> - viewModelProvider.get(PhoneSignInHandler::class.java).initWith(idpConfig) + vm.get(PhoneSignInHandler::class.java).initWith(idp) AuthUI.ANONYMOUS_PROVIDER -> - viewModelProvider.get(AnonymousSignInHandler::class.java).initWith(flowParams) + vm.get(AnonymousSignInHandler::class.java).initWith(flowParams) GoogleAuthProvider.PROVIDER_ID -> - if (authUI.isUseEmulator()) { - viewModelProvider.get(GenericIdpSignInHandler::class.java) - .initWith(GenericIdpSignInHandler.getGenericGoogleConfig()) - } else { - viewModelProvider.get(GoogleSignInHandler::class.java) - .initWith(GoogleSignInHandler.Params(idpConfig)) - } + if (authUI.isUseEmulator()) vm.get(GenericIdpSignInHandler::class.java) + .initWith(GenericIdpSignInHandler.getGenericGoogleConfig()) + else vm.get(GoogleSignInHandler::class.java).initWith(GoogleSignInHandler.Params(idp)) FacebookAuthProvider.PROVIDER_ID -> - if (authUI.isUseEmulator()) { - viewModelProvider.get(GenericIdpSignInHandler::class.java) - .initWith(GenericIdpSignInHandler.getGenericFacebookConfig()) - } else { - viewModelProvider.get(FacebookSignInHandler::class.java).initWith(idpConfig) - } - else -> { - if (!TextUtils.isEmpty(idpConfig.getParams().getString(GENERIC_OAUTH_PROVIDER_ID))) { - viewModelProvider.get(GenericIdpSignInHandler::class.java).initWith(idpConfig) - } else { - throw IllegalStateException("Unknown provider: $providerId") - } - } + if (authUI.isUseEmulator()) vm.get(GenericIdpSignInHandler::class.java) + .initWith(GenericIdpSignInHandler.getGenericFacebookConfig()) + else vm.get(FacebookSignInHandler::class.java).initWith(idp) + else -> + if (!TextUtils.isEmpty(idp.getParams().getString(ExtraConstants.GENERIC_OAUTH_PROVIDER_ID))) + vm.get(GenericIdpSignInHandler::class.java).initWith(idp) + else throw IllegalStateException("Unknown provider $pid") } - mProviders.add(provider) - provider.operation.observe(this, object : ResourceObserver(this) { - override fun onSuccess(response: IdpResponse) { - handleResponse(response) - } - + override fun onSuccess(r: IdpResponse) = handleResult(r, pid) override fun onFailure(e: Exception) { if (e is FirebaseAuthAnonymousUpgradeException) { - finish( - RESULT_CANCELED, - Intent().putExtra(ExtraConstants.IDP_RESPONSE, IdpResponse.from(e)) - ) - return - } - handleResponse(IdpResponse.from(e)) + finish(RESULT_CANCELED, + Intent().putExtra(ExtraConstants.IDP_RESPONSE, IdpResponse.from(e))) + } else handleResult(IdpResponse.from(e), pid) } - - private fun handleResponse(response: IdpResponse) { - // For social providers (unless using an emulator) use the social response handler. - val isSocialResponse = AuthUI.isSocialProvider(providerId) && !authUI.isUseEmulator() - if (!response.isSuccessful) { - mHandler.startSignIn(response) - } else if (isSocialResponse) { - mHandler.startSignIn(response) - } else { - finish(if (response.isSuccessful) RESULT_OK else RESULT_CANCELED, response.toIntent()) - } + private fun handleResult(r: IdpResponse, providerId: String) { + showProgress = false + val social = AuthUI.isSocialProvider(providerId) && + !getAuthUI().isUseEmulator() + if (!r.isSuccessful || social) handler.startSignIn(r) + else finish(RESULT_OK, r.toIntent()) } }) + return provider + } - view.setOnClickListener { - if (isOffline()) { - Snackbar.make(findViewById(android.R.id.content), getString(R.string.fui_no_internet), Snackbar.LENGTH_SHORT) - .show() - return@setOnClickListener + private fun attemptCredentialSignIn() { + val args = flowParams + val supportsPw = ProviderUtils + .getConfigFromIdps(args.providers, EmailAuthProvider.PROVIDER_ID) != null + + if (!(args.enableCredentials && (supportsPw || args.providers.any { + it.providerId == GoogleAuthProvider.PROVIDER_ID + }))) return + + val request = GetCredentialRequest( + listOf( + GetPasswordOption(), + com.google.android.libraries.identity.googleid + .GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(true) + .setServerClientId(getString(R.string.default_web_client_id)) + .build() + ) + ) + + lifecycleScope.launch { + try { + val result = credentialManager.getCredential(this@AuthMethodPickerActivity, request) + handleCredentialManagerResult(result.credential) + } catch (e: GetCredentialException) { + Log.w(TAG, "CredentialManager sign-in failed", e) } - provider.startSignIn(getAuth(), this@AuthMethodPickerActivity, idpConfig.providerId) } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - mHandler.onActivityResult(requestCode, resultCode, data) - for (provider in mProviders) { - provider.onActivityResult(requestCode, resultCode, data) + hideProgress() + + // forward the outcome upstream and close + if (resultCode != RESULT_CANCELED) { + setResult(resultCode, data) + finish() } } - override fun showProgress(message: Int) { - if (customLayout == null) { - mProgressBar?.visibility = View.VISIBLE - mProviderHolder?.let { holder -> - for (i in 0 until holder.childCount) { - val child = holder.getChildAt(i) - child.isEnabled = false - child.alpha = 0.75f + private fun handleCredentialManagerResult(cred: Credential) { + when (cred) { + is PasswordCredential -> { + val email = cred.id; val pw = cred.password + KickoffActivity.mKickstarter.setResult(Resource.forLoading()) + auth.signInWithEmailAndPassword(email, pw) + .addOnSuccessListener { res -> + KickoffActivity.mKickstarter.handleSuccess( + IdpResponse.Builder(User.Builder( + EmailAuthProvider.PROVIDER_ID, email + ).build()).build(), + res + ) + finish() + } + .addOnFailureListener { + if (it is FirebaseAuthInvalidUserException || + it is FirebaseAuthInvalidCredentialsException + ) Identity.getSignInClient(application).signOut() + } + } + is CustomCredential -> { + if (cred.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { + try { + val g = GoogleIdTokenCredential.createFrom(cred.data) + auth.signInWithCredential( + GoogleAuthProvider.getCredential(g.idToken, null) + ).addOnSuccessListener { res -> + KickoffActivity.mKickstarter.handleSuccess( + IdpResponse.Builder(User.Builder( + GoogleAuthProvider.PROVIDER_ID, + g.data.getString("email") + ).build()).setToken(g.idToken).build(), + res + ) + finish() + }.addOnFailureListener { + Log.e(TAG, "Google token sign-in failed", it) + } + } catch (e: GoogleIdTokenParsingException) { + Log.e(TAG, "Bad GoogleIdTokenCredential", e) + } } } + else -> Log.e(TAG, "Unhandled credential ${cred::class.java.simpleName}") } } - override fun hideProgress() { - if (customLayout == null) { - mProgressBar?.visibility = View.INVISIBLE - mProviderHolder?.let { holder -> - for (i in 0 until holder.childCount) { - val child = holder.getChildAt(i) - child.isEnabled = true - child.alpha = 1.0f - } - } + @Composable + private fun ProviderButton(cfg: IdpConfig, onClick: () -> Unit) { + val (iconRes, bgColor, textColor) = when (cfg.providerId) { + GoogleAuthProvider.PROVIDER_ID -> Triple( + R.drawable.fui_ic_googleg_color_24dp, + colorResource(R.color.fui_bgGoogle), + Color(0xFF757575) + ) + FacebookAuthProvider.PROVIDER_ID -> Triple( + R.drawable.fui_ic_facebook_white_22dp, + colorResource(R.color.fui_bgFacebook), + Color.White + ) + TwitterAuthProvider.PROVIDER_ID /* "twitter.com" */ -> Triple( + R.drawable.fui_ic_twitter_bird_white_24dp, + colorResource(R.color.fui_bgTwitter), + Color.White + ) + GithubAuthProvider.PROVIDER_ID /* "github.com" */ -> Triple( + R.drawable.fui_ic_github_white_24dp, + colorResource(R.color.fui_bgGitHub), + Color.White + ) + EmailAuthProvider.PROVIDER_ID, + AuthUI.EMAIL_LINK_PROVIDER -> Triple( + R.drawable.fui_ic_mail_white_24dp, + colorResource(R.color.fui_bgEmail), + Color.White + ) + PhoneAuthProvider.PROVIDER_ID -> Triple( + R.drawable.fui_ic_phone_white_24dp, + colorResource(R.color.fui_bgPhone), + Color.White + ) + AuthUI.ANONYMOUS_PROVIDER -> Triple( + R.drawable.fui_ic_anonymous_white_24dp, + colorResource(R.color.fui_bgAnonymous), + Color.White + ) + AuthUI.MICROSOFT_PROVIDER /* "microsoft.com" */ -> Triple( + R.drawable.fui_ic_microsoft_24dp, + colorResource(R.color.fui_bgMicrosoft), + Color.White + ) + AuthUI.YAHOO_PROVIDER /* "yahoo.com" */ -> Triple( + R.drawable.fui_ic_yahoo_24dp, + colorResource(R.color.fui_bgYahoo), + Color.White + ) + AuthUI.APPLE_PROVIDER /* "apple.com" */ -> Triple( + R.drawable.fui_ic_apple_white_24dp, + colorResource(R.color.fui_bgApple), + Color.White + ) + else -> Triple( + R.drawable.fui_ic_mail_white_24dp, + colorResource(R.color.fui_bgEmail), + Color.White + ) + } + + val label = when (cfg.providerId) { + GoogleAuthProvider.PROVIDER_ID -> + stringResource(R.string.fui_sign_in_with_google) + FacebookAuthProvider.PROVIDER_ID -> + stringResource(R.string.fui_sign_in_with_facebook) + TwitterAuthProvider.PROVIDER_ID -> + stringResource(R.string.fui_sign_in_with_twitter) + GithubAuthProvider.PROVIDER_ID -> + stringResource(R.string.fui_sign_in_with_github) + EmailAuthProvider.PROVIDER_ID, + AuthUI.EMAIL_LINK_PROVIDER -> + stringResource(R.string.fui_sign_in_with_email) + PhoneAuthProvider.PROVIDER_ID -> + stringResource(R.string.fui_sign_in_with_phone) + AuthUI.ANONYMOUS_PROVIDER -> + stringResource(R.string.fui_sign_in_anonymously) + AuthUI.MICROSOFT_PROVIDER -> + stringResource(R.string.fui_sign_in_with_microsoft) + AuthUI.YAHOO_PROVIDER -> + stringResource(R.string.fui_sign_in_with_yahoo) + AuthUI.APPLE_PROVIDER -> + stringResource(R.string.fui_sign_in_with_apple) + else -> cfg.providerId + } + + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = bgColor, + contentColor = textColor + ), + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null + ) + Spacer(Modifier.width(24.dp)) + Text(text = label) } } -} \ No newline at end of file +} + +@Composable +public fun TermsAndPrivacyText( + tosUrl: String, + ppUrl: String, + modifier: Modifier = Modifier +) { + val tosLabel = stringResource(R.string.fui_terms_of_service) + val ppLabel = stringResource(R.string.fui_privacy_policy) + + val fullText = stringResource( + R.string.fui_tos_and_pp, + tosLabel, + ppLabel + ) + + val tosStart = fullText.indexOf(tosLabel).coerceAtLeast(0) + val tosEnd = tosStart + tosLabel.length + val ppStart = fullText.indexOf(ppLabel).coerceAtLeast(0) + val ppEnd = ppStart + ppLabel.length + + val annotated = buildAnnotatedString { + append(fullText) + + addStyle( + style = SpanStyle(fontWeight = FontWeight.Bold), + start = tosStart, + end = tosEnd + ) + addStringAnnotation( + tag = "URL", + annotation = tosUrl, + start = tosStart, + end = tosEnd + ) + + addStyle( + style = SpanStyle(fontWeight = FontWeight.Bold), + start = ppStart, + end = ppEnd + ) + addStringAnnotation( + tag = "URL", + annotation = ppUrl, + start = ppStart, + end = ppEnd + ) + } + + val uriHandler = LocalUriHandler.current + ClickableText( + text = annotated, + style = MaterialTheme.typography.bodySmall.copy(textAlign = TextAlign.Center), + modifier = modifier, + onClick = { offset -> + annotated + .getStringAnnotations(tag = "URL", start = offset, end = offset) + .firstOrNull() + ?.let { uriHandler.openUri(it.item) } + } + ) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/viewmodel/RequestCodes.java b/auth/src/main/java/com/firebase/ui/auth/viewmodel/RequestCodes.java index f30f429e1..7bafef5ca 100644 --- a/auth/src/main/java/com/firebase/ui/auth/viewmodel/RequestCodes.java +++ b/auth/src/main/java/com/firebase/ui/auth/viewmodel/RequestCodes.java @@ -58,6 +58,9 @@ public final class RequestCodes { /** Request code for starter a generic IDP sign-in flow */ public static final int GENERIC_IDP_SIGN_IN_FLOW = 117; + /** Request code for recover password */ + public static final int RECOVER_PASSWORD = 118; + private RequestCodes() { throw new AssertionError("No instance for you!"); } diff --git a/auth/src/main/java/com/firebase/ui/auth/viewmodel/email/EmailProviderResponseHandler.java b/auth/src/main/java/com/firebase/ui/auth/viewmodel/email/EmailProviderResponseHandler.java index 96cdaa8c6..a89e3bff2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/viewmodel/email/EmailProviderResponseHandler.java +++ b/auth/src/main/java/com/firebase/ui/auth/viewmodel/email/EmailProviderResponseHandler.java @@ -11,17 +11,15 @@ import com.firebase.ui.auth.data.model.User; import com.firebase.ui.auth.data.remote.ProfileMerger; import com.firebase.ui.auth.ui.email.WelcomeBackEmailLinkPrompt; -import com.firebase.ui.auth.ui.email.WelcomeBackPasswordPrompt; +import com.firebase.ui.auth.ui.email.WelcomeBackPasswordActivity; import com.firebase.ui.auth.ui.idp.WelcomeBackIdpPrompt; import com.firebase.ui.auth.util.data.AuthOperationManager; import com.firebase.ui.auth.util.data.ProviderUtils; import com.firebase.ui.auth.util.data.TaskFailureLogger; import com.firebase.ui.auth.viewmodel.RequestCodes; import com.firebase.ui.auth.viewmodel.SignInViewModelBase; -import com.google.android.gms.tasks.OnFailureListener; import com.google.android.gms.tasks.OnSuccessListener; import com.google.firebase.auth.AuthCredential; -import com.google.firebase.auth.AuthResult; import com.google.firebase.auth.EmailAuthProvider; import com.google.firebase.auth.FirebaseAuthUserCollisionException; @@ -103,32 +101,27 @@ public void onSuccess(@Nullable String provider) { if (EmailAuthProvider.PROVIDER_ID.equalsIgnoreCase(provider)) { setResult(Resource.forFailure(new IntentRequiredException( - WelcomeBackPasswordPrompt.createIntent( + WelcomeBackPasswordActivity.createIntent( getApplication(), getArguments(), new IdpResponse.Builder(new User.Builder( - EmailAuthProvider.PROVIDER_ID, mEmail).build() - ).build()), - RequestCodes.WELCOME_BACK_EMAIL_FLOW - ))); + EmailAuthProvider.PROVIDER_ID, mEmail).build()).build()), + RequestCodes.WELCOME_BACK_EMAIL_FLOW))); } else if (EMAIL_LINK_PROVIDER.equalsIgnoreCase(provider)) { setResult(Resource.forFailure(new IntentRequiredException( WelcomeBackEmailLinkPrompt.createIntent( getApplication(), getArguments(), new IdpResponse.Builder(new User.Builder( - EMAIL_LINK_PROVIDER, mEmail).build() - ).build()), - RequestCodes.WELCOME_BACK_EMAIL_LINK_FLOW - ))); + EMAIL_LINK_PROVIDER, mEmail).build()).build()), + RequestCodes.WELCOME_BACK_EMAIL_LINK_FLOW))); } else { setResult(Resource.forFailure(new IntentRequiredException( WelcomeBackIdpPrompt.createIntent( getApplication(), getArguments(), new User.Builder(provider, mEmail).build()), - RequestCodes.WELCOME_BACK_IDP_FLOW - ))); + RequestCodes.WELCOME_BACK_IDP_FLOW))); } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/viewmodel/email/WelcomeBackPasswordViewModel.kt b/auth/src/main/java/com/firebase/ui/auth/viewmodel/email/WelcomeBackPasswordViewModel.kt new file mode 100644 index 000000000..32ab3992b --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/viewmodel/email/WelcomeBackPasswordViewModel.kt @@ -0,0 +1,40 @@ +package com.firebase.ui.auth.viewmodel.email + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.firebase.ui.auth.IdpResponse +import com.firebase.ui.auth.data.model.Resource +import com.google.firebase.auth.AuthCredential +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class WelcomeBackPasswordViewModel(application: Application) : AndroidViewModel(application) { + private val handler = WelcomeBackPasswordHandler(application) + + private val _signInState = MutableStateFlow?>(null) + val signInState: StateFlow?> = _signInState + + fun init(flowParams: com.firebase.ui.auth.data.model.FlowParameters) { + handler.init(flowParams) + } + + fun signIn( + email: String, + password: String, + idpResponse: IdpResponse, + credential: AuthCredential? = null + ) { + viewModelScope.launch { + handler.startSignIn(email, password, idpResponse, credential) + handler.getOperation().observeForever { resource -> + _signInState.value = resource + } + } + } + + fun getPendingPassword(): String { + return handler.getPendingPassword() + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/viewmodel/email/WelcomeBackPasswordViewModelFactory.java b/auth/src/main/java/com/firebase/ui/auth/viewmodel/email/WelcomeBackPasswordViewModelFactory.java new file mode 100644 index 000000000..da9a1aebb --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/viewmodel/email/WelcomeBackPasswordViewModelFactory.java @@ -0,0 +1,33 @@ +package com.firebase.ui.auth.viewmodel.email; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.firebase.ui.auth.data.model.FlowParameters; + +/** + * Factory for creating WelcomeBackPasswordViewModel instances. + */ +public class WelcomeBackPasswordViewModelFactory implements ViewModelProvider.Factory { + private final Application mApplication; + private final FlowParameters mFlowParams; + + public WelcomeBackPasswordViewModelFactory(Application application, FlowParameters flowParams) { + mApplication = application; + mFlowParams = flowParams; + } + + @NonNull + @Override + public T create(@NonNull Class modelClass) { + if (modelClass == WelcomeBackPasswordViewModel.class) { + WelcomeBackPasswordViewModel viewModel = new WelcomeBackPasswordViewModel(mApplication); + viewModel.init(mFlowParams); + return (T) viewModel; + } + throw new IllegalArgumentException("Unknown ViewModel class: " + modelClass.getName()); + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/viewmodel/idp/SocialProviderResponseHandler.java b/auth/src/main/java/com/firebase/ui/auth/viewmodel/idp/SocialProviderResponseHandler.java index da2ce914b..465a658b4 100644 --- a/auth/src/main/java/com/firebase/ui/auth/viewmodel/idp/SocialProviderResponseHandler.java +++ b/auth/src/main/java/com/firebase/ui/auth/viewmodel/idp/SocialProviderResponseHandler.java @@ -13,25 +13,20 @@ import com.firebase.ui.auth.data.model.User; import com.firebase.ui.auth.data.remote.ProfileMerger; import com.firebase.ui.auth.ui.email.WelcomeBackEmailLinkPrompt; -import com.firebase.ui.auth.ui.email.WelcomeBackPasswordPrompt; +import com.firebase.ui.auth.ui.email.WelcomeBackPasswordActivity; import com.firebase.ui.auth.ui.idp.WelcomeBackIdpPrompt; import com.firebase.ui.auth.util.FirebaseAuthError; import com.firebase.ui.auth.util.data.AuthOperationManager; import com.firebase.ui.auth.util.data.ProviderUtils; import com.firebase.ui.auth.viewmodel.RequestCodes; import com.firebase.ui.auth.viewmodel.SignInViewModelBase; -import com.google.android.gms.tasks.OnFailureListener; -import com.google.android.gms.tasks.OnSuccessListener; import com.google.firebase.auth.AuthCredential; -import com.google.firebase.auth.AuthResult; import com.google.firebase.auth.EmailAuthProvider; import com.google.firebase.auth.FirebaseAuthException; import com.google.firebase.auth.FirebaseAuthInvalidUserException; import com.google.firebase.auth.FirebaseAuthUserCollisionException; import com.google.firebase.auth.PhoneAuthProvider; -import java.util.List; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; @@ -57,7 +52,8 @@ public void startSignIn(@NonNull final IdpResponse response) { setResult(Resource.forLoading()); - // Recoverable error flows (linking) for Generic OAuth providers are handled here. + // Recoverable error flows (linking) for Generic OAuth providers are handled + // here. // For Generic OAuth providers, the credential is set on the IdpResponse, as // a credential made from the id token/access token cannot be used to sign-in. if (response.hasCredentialForLinking()) { @@ -80,14 +76,13 @@ public void startSignIn(@NonNull final IdpResponse response) { FirebaseAuthException authEx = (FirebaseAuthException) e; FirebaseAuthError fae = FirebaseAuthError.fromException(authEx); if (fae == FirebaseAuthError.ERROR_USER_DISABLED) { - isDisabledUser = true; + isDisabledUser = true; } } if (isDisabledUser) { setResult(Resource.forFailure( - new FirebaseUiException(ErrorCodes.ERROR_USER_DISABLED) - )); + new FirebaseUiException(ErrorCodes.ERROR_USER_DISABLED))); } else if (e instanceof FirebaseAuthUserCollisionException) { final String email = response.getEmail(); if (email == null) { @@ -135,12 +130,12 @@ public void startWelcomeBackFlowForLinking(String provider, IdpResponse response if (provider.equals(EmailAuthProvider.PROVIDER_ID)) { // Start email welcome back flow setResult(Resource.forFailure(new IntentRequiredException( - WelcomeBackPasswordPrompt.createIntent( + WelcomeBackPasswordActivity.createIntent( getApplication(), getArguments(), - response), - RequestCodes.ACCOUNT_LINK_FLOW - ))); + new IdpResponse.Builder(new User.Builder( + EmailAuthProvider.PROVIDER_ID, response.getEmail()).build()).build()), + RequestCodes.ACCOUNT_LINK_FLOW))); } else if (provider.equals(EMAIL_LINK_PROVIDER)) { // Start email link welcome back flow setResult(Resource.forFailure(new IntentRequiredException( @@ -148,8 +143,7 @@ public void startWelcomeBackFlowForLinking(String provider, IdpResponse response getApplication(), getArguments(), response), - RequestCodes.WELCOME_BACK_EMAIL_LINK_FLOW - ))); + RequestCodes.WELCOME_BACK_EMAIL_LINK_FLOW))); } else { // Start Idp welcome back flow setResult(Resource.forFailure(new IntentRequiredException( @@ -158,8 +152,7 @@ public void startWelcomeBackFlowForLinking(String provider, IdpResponse response getArguments(), new User.Builder(provider, response.getEmail()).build(), response), - RequestCodes.ACCOUNT_LINK_FLOW - ))); + RequestCodes.ACCOUNT_LINK_FLOW))); } } diff --git a/auth/src/main/res/layout/fui_auth_method_picker_layout.xml b/auth/src/main/res/layout/fui_auth_method_picker_layout.xml index 95cbdc5e0..8e82bf4a1 100644 --- a/auth/src/main/res/layout/fui_auth_method_picker_layout.xml +++ b/auth/src/main/res/layout/fui_auth_method_picker_layout.xml @@ -6,7 +6,8 @@ android:id="@+id/root" android:layout_width="match_parent" android:layout_height="match_parent" - android:clipToPadding="false"> + android:clipToPadding="false" + android:fitsSystemWindows="true"> - + \ No newline at end of file diff --git a/auth/src/main/res/values/dimens.xml b/auth/src/main/res/values/dimens.xml index 3d8c1a232..c3ff58503 100644 --- a/auth/src/main/res/values/dimens.xml +++ b/auth/src/main/res/values/dimens.xml @@ -8,6 +8,7 @@ 12dp 16dp 16dp + 56dp 0dp 3dp @@ -15,4 +16,4 @@ 2dp 48dp - + \ No newline at end of file diff --git a/auth/src/main/res/values/styles.xml b/auth/src/main/res/values/styles.xml index e0073acd1..79c82638d 100644 --- a/auth/src/main/res/values/styles.xml +++ b/auth/src/main/res/values/styles.xml @@ -1,9 +1,15 @@ - - - @@ -223,67 +231,78 @@ @dimen/fui_button_inset_right - - - - - - - - - - - - + \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java b/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java index 2efed02da..802f9438c 100644 --- a/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java +++ b/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java @@ -52,13 +52,12 @@ public final class TestHelper { private static final String TAG = "TestHelper"; - private static final String DEFAULT_APP_NAME = "[DEFAULT]"; + private static final String DEFAULT_APP_NAME = "test-app"; private static final String MICROSOFT_PROVIDER = "microsoft.com"; public static final FirebaseApp MOCK_APP; private static Context CONTEXT = ApplicationProvider.getApplicationContext(); - static { FirebaseApp app = mock(FirebaseApp.class); when(app.get(eq(FirebaseAuth.class))).thenReturn(mock(FirebaseAuth.class)); @@ -90,7 +89,7 @@ private static void initializeApp(Context context) { FirebaseApp.initializeApp(context, new FirebaseOptions.Builder() .setApiKey("fake") .setApplicationId("fake") - .build()); + .build(), DEFAULT_APP_NAME); } private static void initializeProviders() { @@ -125,14 +124,14 @@ public static FlowParameters getFlowParameters(Collection providerIds) { } public static FlowParameters getFlowParameters(Collection providerIds, - boolean enableAnonymousUpgrade) { + boolean enableAnonymousUpgrade) { return getFlowParameters(providerIds, enableAnonymousUpgrade, null, false); } public static FlowParameters getFlowParameters(Collection providerIds, - boolean enableAnonymousUpgrade, - AuthMethodPickerLayout customLayout, - boolean hasDefaultEmail) { + boolean enableAnonymousUpgrade, + AuthMethodPickerLayout customLayout, + boolean hasDefaultEmail) { List idpConfigs = new ArrayList<>(); for (String providerId : providerIds) { switch (providerId) { @@ -151,14 +150,15 @@ public static FlowParameters getFlowParameters(Collection providerIds, case EMAIL_LINK_PROVIDER: idpConfigs.add(new IdpConfig.EmailBuilder().enableEmailLinkSignIn() .setActionCodeSettings(ActionCodeSettings.newBuilder().setUrl("URL") - .setHandleCodeInApp(true).build()).build()); + .setHandleCodeInApp(true).build()) + .build()); break; case EmailAuthProvider.PROVIDER_ID: - if (hasDefaultEmail) { idpConfigs.add(new IdpConfig.EmailBuilder() + if (hasDefaultEmail) { + idpConfigs.add(new IdpConfig.EmailBuilder() .setDefaultEmail(TestConstants.EMAIL) .build()); - } else - { + } else { idpConfigs.add(new IdpConfig.EmailBuilder().build()); } break; diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/email/CheckEmailScreenTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/email/CheckEmailScreenTest.kt new file mode 100644 index 000000000..471440ad1 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/email/CheckEmailScreenTest.kt @@ -0,0 +1,117 @@ +package com.firebase.ui.auth.ui.email + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.firebase.ui.auth.data.model.FlowParameters +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Matches any node that exposes an `Error` semantics property. */ +private fun hasAnyError(): SemanticsMatcher = + SemanticsMatcher("has any error") { node -> + node.config.contains(SemanticsProperties.Error) + } + +/** + * UI tests for [CheckEmailScreen] – no Mockito required. + */ +@RunWith(AndroidJUnit4::class) +class CheckEmailScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var flowParameters: FlowParameters + + @Before + fun setUp() { + flowParameters = FlowParameters( + appName = "test-app", + providers = emptyList(), + defaultProvider = null, + themeId = 0, + logoId = 0, + termsOfServiceUrl = "https://example.com/terms", + privacyPolicyUrl = "https://example.com/privacy", + enableCredentials = false, + enableAnonymousUpgrade = false, + alwaysShowProviderChoice = true, + lockOrientation = false, + emailLink = null, + passwordResetSettings = null, + authMethodPickerLayout = null + ) + } + + @Test + fun initialEmail_isDisplayed() { + val initial = "jane@invertase.io" + + composeTestRule.setContent { + CheckEmailScreen( + flowParameters = flowParameters, + initialEmail = initial, + onExistingEmailUser = {}, + onExistingIdpUser = {}, + onNewUser = {}, + onDeveloperFailure = {} + ) + } + + composeTestRule + .onNodeWithText(initial, substring = false, ignoreCase = true) + .assertIsDisplayed() + } + + @Test + fun enteringValidEmail_andClickingSignIn_invokesCallback() { + var callbackInvoked = false + + composeTestRule.setContent { + CheckEmailScreen( + flowParameters = flowParameters, + onExistingEmailUser = { callbackInvoked = true }, + onExistingIdpUser = {}, + onNewUser = {}, + onDeveloperFailure = {} + ) + } + + composeTestRule.onNodeWithText("Email", substring = true) + .performTextInput("test@example.com") + + composeTestRule.onNodeWithText("Sign in", substring = true) + .performClick() + + composeTestRule.waitUntil(timeoutMillis = 2_000) { callbackInvoked } + assertThat(callbackInvoked).isTrue() + } + + @Test + fun emptyEmail_andClickingSignUp_setsTextFieldError_andDoesNotInvokeCallback() { + var callbackInvoked = false + + composeTestRule.setContent { + CheckEmailScreen( + flowParameters = flowParameters, + onExistingEmailUser = {}, + onExistingIdpUser = {}, + onNewUser = { callbackInvoked = true }, + onDeveloperFailure = {} + ) + } + + composeTestRule + .onNodeWithText("Sign up", substring = true, ignoreCase = true) + .performClick() + + composeTestRule.onNode(hasAnyError()).assertExists() + + assertThat(callbackInvoked).isFalse() + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/email/EmailActivityTest.java b/auth/src/test/java/com/firebase/ui/auth/ui/email/EmailActivityTest.java index 299709453..70bd61ab0 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/email/EmailActivityTest.java +++ b/auth/src/test/java/com/firebase/ui/auth/ui/email/EmailActivityTest.java @@ -74,104 +74,6 @@ public void testOnCreate_emailLinkNormalFlow_expectCheckEmailFlowStarted() { emailActivity.getSupportFragmentManager().findFragmentByTag(CheckEmailFragment.TAG); } - @Test - public void testOnCreate_emailLinkLinkingFlow_expectSendEmailLinkFlowStarted() { - // This is normally done by EmailLinkSendEmailHandler, saving the IdpResponse is done - // in EmailActivity but it will not be saved if we haven't previously set the email - EmailLinkPersistenceManager.getInstance().saveEmail( - ApplicationProvider.getApplicationContext(), - EMAIL, TestConstants.SESSION_ID, TestConstants.UID); - - EmailActivity emailActivity = createActivity(AuthUI.EMAIL_LINK_PROVIDER, true, false); - - EmailLinkFragment fragment = (EmailLinkFragment) emailActivity - .getSupportFragmentManager().findFragmentByTag(EmailLinkFragment.TAG); - assertThat(fragment).isNotNull(); - - EmailLinkPersistenceManager persistenceManager = EmailLinkPersistenceManager.getInstance(); - IdpResponse response = persistenceManager.retrieveSessionRecord( - ApplicationProvider.getApplicationContext()).getIdpResponseForLinking(); - - assertThat(response.getProviderType()).isEqualTo(GoogleAuthProvider.PROVIDER_ID); - assertThat(response.getEmail()).isEqualTo(EMAIL); - assertThat(response.getIdpToken()).isEqualTo(ID_TOKEN); - assertThat(response.getIdpSecret()).isEqualTo(SECRET); - } - - // @Test TODO(lsirac): uncomment after figuring out why this no longer works - public void testOnTroubleSigningIn_expectTroubleSigningInFragment() { - EmailActivity emailActivity = createActivity(AuthUI.EMAIL_LINK_PROVIDER); - emailActivity.onTroubleSigningIn(EMAIL); - TroubleSigningInFragment fragment = (TroubleSigningInFragment) emailActivity - .getSupportFragmentManager().findFragmentByTag(TroubleSigningInFragment.TAG); - assertThat(fragment).isNotNull(); - } - - @Test - public void testOnClickResendEmail_expectSendEmailLinkFlowStarted() { - EmailActivity emailActivity = createActivity(AuthUI.EMAIL_LINK_PROVIDER); - emailActivity.onClickResendEmail(EMAIL); - shadowOf(Looper.getMainLooper()).idle(); - EmailLinkFragment fragment = (EmailLinkFragment) emailActivity - .getSupportFragmentManager().findFragmentByTag(EmailLinkFragment.TAG); - assertThat(fragment).isNotNull(); - } - - @Test - public void testSignUpButton_validatesFields() { - EmailActivity emailActivity = createActivity(EmailAuthProvider.PROVIDER_ID); - // Trigger RegisterEmailFragment (bypass check email) - emailActivity.onNewUser( - new User.Builder(EmailAuthProvider.PROVIDER_ID, TestConstants.EMAIL).build()); - shadowOf(Looper.getMainLooper()).idle(); - Button button = emailActivity.findViewById(R.id.button_create); - button.performClick(); - TextInputLayout nameLayout = emailActivity.findViewById(R.id.name_layout); - TextInputLayout passwordLayout = emailActivity.findViewById(R.id.password_layout); - assertEquals( - emailActivity.getString(R.string.fui_missing_first_and_last_name), - nameLayout.getError().toString()); - assertEquals( - String.format( - emailActivity.getResources().getQuantityString( - R.plurals.fui_error_weak_password, - R.integer.fui_min_password_length), - emailActivity.getResources() - .getInteger(R.integer.fui_min_password_length) - ), - passwordLayout.getError().toString()); - } - - @Test - public void testSetDefaultEmail_validField() { - EmailActivity emailActivity = createActivity(EmailAuthProvider.PROVIDER_ID, false, true); - CheckEmailFragment fragment = (CheckEmailFragment) emailActivity - .getSupportFragmentManager().findFragmentByTag(CheckEmailFragment.TAG); - assertThat(fragment).isNotNull(); - TextInputEditText email = emailActivity.findViewById(R.id.email); - assertEquals(TestConstants.EMAIL, email.getText().toString()); - } - - @Test - public void testSetDefaultEmail_expectWelcomeBackPasswordPrompt() { - EmailActivity emailActivity = createActivity(EmailAuthProvider.PROVIDER_ID, false, true); - emailActivity.onExistingEmailUser(new User.Builder(EmailAuthProvider.PROVIDER_ID, TestConstants.EMAIL).build()); - ShadowActivity.IntentForResult nextIntent = - Shadows.shadowOf(emailActivity).getNextStartedActivityForResult(); - assertEquals(WelcomeBackPasswordPrompt.class.getName(), - nextIntent.intent.getComponent().getClassName()); - } - - @Test - public void testSetDefaultEmail_expectRegisterEmailFragment() { - EmailActivity emailActivity = createActivity(EmailAuthProvider.PROVIDER_ID, false, true); - emailActivity.onNewUser(new User.Builder(EmailAuthProvider.PROVIDER_ID, TestConstants.EMAIL).build()); - shadowOf(Looper.getMainLooper()).idle(); - RegisterEmailFragment registerEmailFragment = (RegisterEmailFragment) emailActivity - .getSupportFragmentManager().findFragmentByTag(RegisterEmailFragment.TAG); - assertThat(registerEmailFragment).isNotNull(); - } - private EmailActivity createActivity(String providerId) { return createActivity(providerId, false, false); } diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/email/RegisterEmailScreenTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/email/RegisterEmailScreenTest.kt new file mode 100644 index 000000000..802d99620 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/email/RegisterEmailScreenTest.kt @@ -0,0 +1,118 @@ +package com.firebase.ui.auth.ui.email + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.firebase.ui.auth.data.model.FlowParameters +import com.firebase.ui.auth.data.model.User +import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.EmailAuthProvider +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +private fun hasAnyError(): SemanticsMatcher = + SemanticsMatcher("has any error") { it.config.contains(SemanticsProperties.Error) } + +private val signUpButton = hasText("Sign up", ignoreCase = true).and(hasClickAction()) + + +@RunWith(AndroidJUnit4::class) +class RegisterEmailScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var flowParameters: FlowParameters + + @Before + fun setUp() { + flowParameters = FlowParameters( + appName = "test-app", + providers = emptyList(), + defaultProvider = null, + themeId = 0, + logoId = 0, + termsOfServiceUrl = "https://example.com/terms", + privacyPolicyUrl = "https://example.com/privacy", + enableCredentials = false, + enableAnonymousUpgrade = false, + alwaysShowProviderChoice = true, + lockOrientation = false, + emailLink = null, + passwordResetSettings = null, + authMethodPickerLayout = null + ) + } + + @Test + fun initialEmailAndName_areDisplayed() { + val initialUser = User.Builder(EmailAuthProvider.PROVIDER_ID, "alice@invertase.io") + .setName("Alice") + .build() + + composeTestRule.setContent { + RegisterEmailScreen( + flowParameters = flowParameters, + user = initialUser, + onRegisterSuccess = { _, _ -> }, + onRegisterError = {} + ) + } + + composeTestRule.onNodeWithText("alice@invertase.io", substring = false, ignoreCase = true) + .assertIsDisplayed() + composeTestRule.onNodeWithText("Alice", substring = false, ignoreCase = true) + .assertIsDisplayed() + } + + @Test + fun enteringValidData_andClickingSignUp_invokesCallback() { + var callbackInvoked = false + + composeTestRule.setContent { + RegisterEmailScreen( + flowParameters = flowParameters, + user = User.Builder(EmailAuthProvider.PROVIDER_ID, "").build(), + onRegisterSuccess = { _, _ -> callbackInvoked = true }, + onRegisterError = {} + ) + } + + composeTestRule.onNodeWithText("Email", substring = true) + .performTextInput("bob@example.com") + composeTestRule.onNodeWithText("Name", substring = true, ignoreCase = true) + .performTextInput("Bob") + composeTestRule.onNodeWithText("Password", substring = true) + .performTextInput("password123") + + composeTestRule.onNode(signUpButton).performClick() + + composeTestRule.waitUntil(timeoutMillis = 2_000) { callbackInvoked } + assertThat(callbackInvoked).isTrue() + } + + @Test + fun emptyForm_andClickingSignUp_setsError_andDoesNotInvokeCallback() { + var callbackInvoked = false + + composeTestRule.setContent { + RegisterEmailScreen( + flowParameters = flowParameters, + user = User.Builder(EmailAuthProvider.PROVIDER_ID, "").build(), + onRegisterSuccess = { _, _ -> callbackInvoked = true }, + onRegisterError = {} + ) + } + + composeTestRule.onNode(signUpButton).performClick() + + composeTestRule + .onNode(hasText("Email", ignoreCase = true).and(hasAnyError())) + .assertExists() + + assertThat(callbackInvoked).isFalse() + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPromptTest.java b/auth/src/test/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPromptTest.java deleted file mode 100644 index ef5681776..000000000 --- a/auth/src/test/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPromptTest.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2016 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.firebase.ui.auth.ui.email; - -import android.content.Intent; -import android.content.res.Resources; -import android.widget.Button; - -import com.firebase.ui.auth.IdpResponse; -import com.firebase.ui.auth.R; -import com.firebase.ui.auth.data.model.User; -import com.firebase.ui.auth.testhelpers.TestConstants; -import com.firebase.ui.auth.testhelpers.TestHelper; -import com.google.android.material.textfield.TextInputLayout; -import com.google.firebase.auth.EmailAuthProvider; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.android.controller.ActivityController; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.Shadows; -import org.robolectric.shadows.ShadowActivity; - -import java.util.Collections; - -import androidx.test.core.app.ApplicationProvider; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; - -@RunWith(RobolectricTestRunner.class) -public class WelcomeBackPasswordPromptTest { - - @Before - public void setUp() { - TestHelper.initialize(); - } - - private WelcomeBackPasswordPrompt createActivity() { - Intent startIntent = WelcomeBackPasswordPrompt.createIntent( - ApplicationProvider.getApplicationContext(), - TestHelper.getFlowParameters(Collections.singletonList(EmailAuthProvider.PROVIDER_ID)), - new IdpResponse.Builder(new User.Builder( - EmailAuthProvider.PROVIDER_ID, TestConstants.EMAIL - ).build()).build()); - - ActivityController controller = - Robolectric.buildActivity(WelcomeBackPasswordPrompt.class, startIntent); - WelcomeBackPasswordPrompt activity = controller.get(); - activity.setTheme(R.style.Theme_AppCompat); - return controller.create().visible().get(); - } - - @Test - public void testSignInButton_validatesFields() { - WelcomeBackPasswordPrompt welcomeBack = createActivity(); - Button signIn = welcomeBack.findViewById(R.id.button_done); - signIn.performClick(); - - TextInputLayout passwordLayout = welcomeBack.findViewById(R.id.password_layout); - assertEquals( - welcomeBack.getString(R.string.fui_error_invalid_password), - passwordLayout.getError().toString()); - - // should block and not start a new activity - ShadowActivity.IntentForResult nextIntent = - Shadows.shadowOf(welcomeBack).getNextStartedActivityForResult(); - assertNull(nextIntent); - } -} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPromptTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPromptTest.kt new file mode 100644 index 000000000..d314225f4 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPromptTest.kt @@ -0,0 +1,97 @@ +package com.firebase.ui.auth.ui.email + +import android.app.Application +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.firebase.ui.auth.IdpResponse +import com.firebase.ui.auth.data.model.FlowParameters +import com.firebase.ui.auth.data.model.User +import com.firebase.ui.auth.viewmodel.email.WelcomeBackPasswordViewModel +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.EmailAuthProvider +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + + +private fun hasAnyError(): SemanticsMatcher = + SemanticsMatcher("has any error") { it.config.contains(SemanticsProperties.Error) } + +private val signInButton = + hasText("Sign in", ignoreCase = true).and(hasClickAction()) + +@RunWith(AndroidJUnit4::class) +class WelcomeBackPasswordPromptTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var flowParameters: FlowParameters + private lateinit var idpResponse: IdpResponse + private lateinit var appContext: Application + + @Before + fun setUp() { + appContext = ApplicationProvider.getApplicationContext() + + if (FirebaseApp.getApps(appContext).none { it.name == "test-app" }) { + val opts = FirebaseOptions.Builder() + .setApplicationId("1:123:android:dummy") // minimal stub values + .setApiKey("dummy") + .setProjectId("dummy") + .build() + FirebaseApp.initializeApp(appContext, opts, "test-app") + } + + flowParameters = FlowParameters( + appName = "test-app", + providers = emptyList(), + defaultProvider = null, + themeId = 0, + logoId = 0, + termsOfServiceUrl = "https://example.com/terms", + privacyPolicyUrl = "https://example.com/privacy", + enableCredentials = false, + enableAnonymousUpgrade = false, + alwaysShowProviderChoice = true, + lockOrientation = false, + emailLink = null, + passwordResetSettings = null, + authMethodPickerLayout = null + ) + + val user = User.Builder(EmailAuthProvider.PROVIDER_ID, "jane@invertase.io").build() + idpResponse = IdpResponse.Builder(user).build() + } + + @Test + fun blankPassword_showsError_andDoesNotInvokeSuccess() { + var successCalled = false + + composeTestRule.setContent { + WelcomeBackPasswordPrompt( + flowParameters = flowParameters, + email = "jane@invertase.io", + idpResponse = idpResponse, + onSignInSuccess = { successCalled = true }, + onSignInError = {}, + onForgotPassword = {}, + viewModel = WelcomeBackPasswordViewModel(appContext) + ) + } + + composeTestRule.onNode(signInButton).performClick() + + composeTestRule + .onNode(hasText("Password", ignoreCase = true).and(hasAnyError())) + .assertExists() + + assertThat(successCalled).isFalse() + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivityTest.java b/auth/src/test/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivityTest.java deleted file mode 100644 index f2aa11405..000000000 --- a/auth/src/test/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivityTest.java +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright 2016 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.firebase.ui.auth.ui.idp; - -import android.content.Intent; -import android.widget.Button; -import android.widget.LinearLayout; - -import com.firebase.ui.auth.AuthMethodPickerLayout; -import com.firebase.ui.auth.AuthUI; -import com.firebase.ui.auth.R; -import com.firebase.ui.auth.testhelpers.TestHelper; -import com.firebase.ui.auth.ui.email.EmailActivity; -import com.firebase.ui.auth.ui.phone.PhoneActivity; -import com.google.firebase.auth.EmailAuthProvider; -import com.google.firebase.auth.PhoneAuthProvider; -import com.google.firebase.auth.TwitterAuthProvider; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.android.controller.ActivityController; -import org.robolectric.Shadows; -import org.robolectric.shadows.ShadowActivity; - -import java.util.Arrays; -import java.util.List; - -import androidx.test.core.app.ApplicationProvider; - -import static junit.framework.Assert.assertEquals; - -@RunWith(RobolectricTestRunner.class) -public class AuthMethodPickerActivityTest { - @Before - public void setUp() { - TestHelper.initialize(); - } - - @Test - public void testAllProvidersArePopulated() { - // Exclude Facebook until the `NoClassDefFoundError: com/facebook/common/R$style` exception - // is fixed. - List providers = Arrays.asList( - // GoogleAuthProvider.PROVIDER_ID is removed to avoid initializing CredentialManager, - // which can throw an exception. - TwitterAuthProvider.PROVIDER_ID, - EmailAuthProvider.PROVIDER_ID, - PhoneAuthProvider.PROVIDER_ID, - AuthUI.ANONYMOUS_PROVIDER); - - AuthMethodPickerActivity authMethodPickerActivity = createActivity(providers); - - assertEquals(providers.size(), - ((LinearLayout) authMethodPickerActivity.findViewById(R.id.btn_holder)) - .getChildCount()); - } - - @Test - public void testEmailLoginFlow() { - List providers = Arrays.asList(EmailAuthProvider.PROVIDER_ID); - - AuthMethodPickerActivity authMethodPickerActivity = createActivity(providers); - - Button emailButton = authMethodPickerActivity.findViewById(R.id.email_button); - emailButton.performClick(); - ShadowActivity.IntentForResult nextIntent = - Shadows.shadowOf(authMethodPickerActivity).getNextStartedActivityForResult(); - - assertEquals( - EmailActivity.class.getName(), - nextIntent.intent.getComponent().getClassName()); - } - - @Test - public void testPhoneLoginFlow() { - List providers = Arrays.asList(PhoneAuthProvider.PROVIDER_ID); - - AuthMethodPickerActivity authMethodPickerActivity = createActivity(providers); - - Button phoneButton = authMethodPickerActivity.findViewById(R.id.phone_button); - phoneButton.performClick(); - ShadowActivity.IntentForResult nextIntent = - Shadows.shadowOf(authMethodPickerActivity).getNextStartedActivityForResult(); - - assertEquals( - PhoneActivity.class.getName(), - nextIntent.intent.getComponent().getClassName()); - } - - @Test - public void testCustomAuthMethodPickerLayout() { - List providers = Arrays.asList(EmailAuthProvider.PROVIDER_ID); - - AuthMethodPickerLayout customLayout = new AuthMethodPickerLayout - .Builder(R.layout.fui_provider_button_email) - .setEmailButtonId(R.id.email_button) - .build(); - - AuthMethodPickerActivity authMethodPickerActivity = createActivityWithCustomLayout(providers, customLayout, false); - - Button emailButton = authMethodPickerActivity.findViewById(R.id.email_button); - emailButton.performClick(); - - // Expected result: Directing users to EmailActivity - ShadowActivity.IntentForResult nextIntent = - Shadows.shadowOf(authMethodPickerActivity).getNextStartedActivityForResult(); - assertEquals( - EmailActivity.class.getName(), - nextIntent.intent.getComponent().getClassName()); - } - - @Test - public void testCustomAuthMethodPickerLayoutWithEmailLink() { - List providers = Arrays.asList(EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD); - - AuthMethodPickerLayout customLayout = new AuthMethodPickerLayout - .Builder(R.layout.fui_provider_button_email) - .setEmailButtonId(R.id.email_button) - .build(); - - AuthMethodPickerActivity authMethodPickerActivity = createActivityWithCustomLayout(providers, customLayout, false); - - Button emailButton = authMethodPickerActivity.findViewById(R.id.email_button); - emailButton.performClick(); - - // Expected result: Directing users to EmailActivity - ShadowActivity.IntentForResult nextIntent = - Shadows.shadowOf(authMethodPickerActivity).getNextStartedActivityForResult(); - assertEquals( - EmailActivity.class.getName(), - nextIntent.intent.getComponent().getClassName()); - } - - @Test - public void testCustomAuthMethodPickerLayoutWithDefaultEmail() { - List providers = Arrays.asList(EmailAuthProvider.PROVIDER_ID); - - AuthMethodPickerLayout customLayout = new AuthMethodPickerLayout - .Builder(R.layout.fui_provider_button_email) - .setEmailButtonId(R.id.email_button) - .build(); - - AuthMethodPickerActivity authMethodPickerActivity = createActivityWithCustomLayout(providers, customLayout, true); - Button emailButton = authMethodPickerActivity.findViewById(R.id.email_button); - emailButton.performClick(); - - // Expected result: Directing users to EmailActivity - ShadowActivity.IntentForResult nextIntent = - Shadows.shadowOf(authMethodPickerActivity).getNextStartedActivityForResult(); - assertEquals( - EmailActivity.class.getName(), - nextIntent.intent.getComponent().getClassName()); - } - - private AuthMethodPickerActivity createActivityWithCustomLayout(List providers, - AuthMethodPickerLayout layout, - boolean hasDefaultEmail) { - Intent startIntent = AuthMethodPickerActivity.createIntent( - ApplicationProvider.getApplicationContext(), - TestHelper.getFlowParameters(providers, false, layout, hasDefaultEmail)); - - ActivityController controller = - Robolectric.buildActivity(AuthMethodPickerActivity.class, startIntent); - AuthMethodPickerActivity activity = controller.get(); - activity.setTheme(R.style.Theme_AppCompat); - return controller.create().visible().get(); - } - - private AuthMethodPickerActivity createActivity(List providers) { - Intent startIntent = AuthMethodPickerActivity.createIntent( - ApplicationProvider.getApplicationContext(), - TestHelper.getFlowParameters(providers)); - - ActivityController controller = - Robolectric.buildActivity(AuthMethodPickerActivity.class, startIntent); - AuthMethodPickerActivity activity = controller.get(); - activity.setTheme(R.style.Theme_AppCompat); - return controller.create().visible().get(); - } -} diff --git a/auth/src/test/java/com/firebase/ui/auth/viewmodel/SocialProviderResponseHandlerTest.java b/auth/src/test/java/com/firebase/ui/auth/viewmodel/SocialProviderResponseHandlerTest.java index 0fb0e43ca..11d536e1d 100644 --- a/auth/src/test/java/com/firebase/ui/auth/viewmodel/SocialProviderResponseHandlerTest.java +++ b/auth/src/test/java/com/firebase/ui/auth/viewmodel/SocialProviderResponseHandlerTest.java @@ -18,7 +18,6 @@ import com.firebase.ui.auth.testhelpers.ResourceMatchers; import com.firebase.ui.auth.testhelpers.TestConstants; import com.firebase.ui.auth.testhelpers.TestHelper; -import com.firebase.ui.auth.ui.email.WelcomeBackPasswordPrompt; import com.firebase.ui.auth.ui.idp.WelcomeBackIdpPrompt; import com.firebase.ui.auth.viewmodel.idp.SocialProviderResponseHandler; import com.firebase.ui.auth.viewmodel.credentialmanager.CredentialManagerHandler; @@ -333,10 +332,7 @@ public void testSignInIdp_anonymousUserUpgradeEnabledAndExistingPasswordUserWith // Make sure that we are trying to start the WelcomeBackIdpPrompt activity IntentRequiredException e = ((IntentRequiredException) resolveCaptor.getValue().getException()); - assertThat(e.getIntent().getComponent().getClassName()) - .isEqualTo(WelcomeBackPasswordPrompt.class.toString().split(" ")[1]); - assertThat(IdpResponse.fromResultIntent(e.getIntent())).isEqualTo(response); } private void setupAnonymousUpgrade() { diff --git a/build.gradle.kts b/build.gradle.kts index c87edeee9..dff7efda0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,6 +19,9 @@ buildscript { plugins { id("com.github.ben-manes.versions") version "0.20.0" + id("com.android.application") version "8.2.0" apply false + id("com.android.library") version "8.2.0" apply false + id("org.jetbrains.compose") version "1.7.3" apply false } allprojects { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 3fea4587b..077b19369 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -5,8 +5,8 @@ object Config { private const val kotlinVersion = "2.1.0" object SdkVersions { - const val compile = 34 - const val target = 34 + const val compile = 35 + const val target = 35 const val min = 23 } diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index dd063c8b0..000000000 --- a/settings.gradle +++ /dev/null @@ -1,38 +0,0 @@ -// https://docs.gradle.com/enterprise/gradle-plugin/#gradle_6_x_and_later -pluginManagement { - repositories { - gradlePluginPortal() - google() - mavenCentral() - } -} - -plugins { - id "com.gradle.enterprise" version "3.3.3" - id "org.jetbrains.dokka" version "2.0.0" apply false -} - -gradleEnterprise { - buildScan { - termsOfServiceUrl = "https://gradle.com/terms-of-service" - termsOfServiceAgree = "yes" - } -} - -rootProject.buildFileName = 'build.gradle.kts' - -include( - ":app", - - ":library", - ":auth", - ":common", - ":database", - ":firestore", - ":storage", - - ":lint", - ":proguard-tests", - ":internal:lint", - ":internal:lintchecks" -) diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..e0dd1f844 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,50 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } + + val kotlinVersion = "2.0.0-RC2" + val composeVersion = "1.8.0-beta02" + val agpVersion = "8.5.0-beta01" + val dokkaVersion = "2.0.0" + val develocityVersion = "4.0" + + plugins { + id("com.android.application") version agpVersion + id("org.jetbrains.kotlin.android") version kotlinVersion + id("org.jetbrains.kotlin.plugin.compose") version kotlinVersion + id("org.jetbrains.compose") version composeVersion + id("org.jetbrains.dokka") version dokkaVersion + id("com.gradle.develocity") version develocityVersion + } +} + +plugins { + id("com.gradle.develocity") version "4.0" + id("org.jetbrains.dokka") version "2.0.0" apply false +} + +develocity { + buildScan { + termsOfUseUrl.set("https://gradle.com/help/legal-terms-of-use") + termsOfUseAgree.set("yes") + } +} + +rootProject.buildFileName = "build.gradle.kts" + +include( + ":app", + ":library", + ":auth", + ":common", + ":database", + ":firestore", + ":storage", + ":lint", + ":proguard-tests", + ":internal:lint", + ":internal:lintchecks" +) \ No newline at end of file