diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cec75d966..77fd210a2 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") + 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..dd64da903 --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/ChooserActivity.kt @@ -0,0 +1,140 @@ +/* + * 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(AuthUiActivity::class.java, + R.string.title_auth_activity, R.string.desc_auth), + 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..b66408199 --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/auth/compose/AuthComposeActivity.kt @@ -0,0 +1,61 @@ +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.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import com.firebase.ui.auth.AuthUI +import com.firebase.ui.auth.data.model.FirebaseAuthUIAuthenticationResult +import com.firebase.uidemo.R +import com.firebase.uidemo.auth.SignedInActivity +import com.firebase.uidemo.ui.theme.FirebaseUIDemoTheme + +class AuthComposeActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + FirebaseUIDemoTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + AuthScreen { result -> + handleSignInResponse(result) + } + } + } + } + } + + private fun handleSignInResponse(result: FirebaseAuthUIAuthenticationResult) { + when (result.resultCode) { + RESULT_OK -> { + // Successfully signed in + val response = result.idpResponse + startActivity(SignedInActivity.createIntent(this, response)) + finish() + } + else -> { + // Sign in failed + val response = result.idpResponse + if (response == null) { + // User pressed back button + finish() + return + } + // Handle other error cases + } + } + } + + companion object { + fun createIntent(context: Context): Intent { + return Intent(context, AuthComposeActivity::class.java) + } + } +} \ No newline at end of file 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..b8d46137c --- /dev/null +++ b/app/src/main/java/com/firebase/uidemo/auth/compose/AuthScreen.kt @@ -0,0 +1,41 @@ +package com.firebase.uidemo.auth.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +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.data.model.FirebaseAuthUIAuthenticationResult +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.uidemo.R +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.GoogleAuthProvider + +@Composable +fun AuthScreen( + onSignInResult: (FirebaseAuthUIAuthenticationResult) -> Unit +) { + val providers = listOf( + IdpConfig.GoogleBuilder().build(), + IdpConfig.EmailBuilder().build(), + ) + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + FirebaseAuthUI( + providers = providers, + onSignInResult = onSignInResult, + theme = R.style.AppTheme, + logo = R.drawable.firebase_auth_120dp, + tosUrl = "https://www.google.com/policies/terms/", + privacyPolicyUrl = "https://www.google.com/policies/privacy/", + enableCredentials = true, + enableAnonymousUpgrade = false + ) + } +} \ No newline at end of file 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/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..b09c4bfdf 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,47 @@ 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") + + 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) @@ -104,4 +113,4 @@ dependencies { testImplementation(Config.Libs.Provider.facebook) debugImplementation(project(":internal:lintchecks")) -} +} \ No newline at end of file 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..c1499c108 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthUI.kt @@ -0,0 +1,72 @@ +package com.firebase.ui.auth.compose + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +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 +import kotlinx.coroutines.launch + +/** + * A composable function that provides Firebase Auth UI functionality. + * + * @param providers List of identity providers to show in the UI + * @param onSignInResult Callback for handling sign-in results + * @param theme Theme resource ID for the UI + * @param logo Logo resource ID for the UI + * @param tosUrl Terms of service URL + * @param privacyPolicyUrl Privacy policy URL + * @param enableCredentials Whether to enable credential manager + * @param enableAnonymousUpgrade Whether to enable anonymous user upgrade + */ +@Composable +fun FirebaseAuthUI( + providers: List, + 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 context = LocalContext.current + val auth = remember { FirebaseAuth.getInstance() } + val authUI = remember { AuthUI.getInstance() } + val scope = rememberCoroutineScope() + + val signInLauncher = rememberLauncherForActivityResult( + contract = FirebaseAuthUIActivityResultContract(), + onResult = onSignInResult + ) + + 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() + .apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + + scope.launch { + signInLauncher.launch(signInIntent) + } +} \ 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..6098405a6 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,456 @@ 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() } - 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) + } + } + + @Composable + private 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) } + } + ) } } \ No newline at end of file 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/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