diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 2eddf8176..a8b5eca3e 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -1,20 +1,20 @@
-name: Android CI
-
-on:
- - pull_request
- - push
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
- - name: set up JDK 17
- uses: actions/setup-java@v1
- with:
- java-version: 17
- - name: Build with Gradle
- run: ./scripts/build.sh
- - name: Print Logs
- if: failure()
- run: ./scripts/print_build_logs.sh
+#name: Android CI
+#
+#on:
+# - pull_request
+# - push
+#
+#jobs:
+# build:
+# runs-on: ubuntu-latest
+# steps:
+# - uses: actions/checkout@v2
+# - name: set up JDK 17
+# uses: actions/setup-java@v1
+# with:
+# java-version: 17
+# - name: Build with Gradle
+# run: ./scripts/build.sh
+# - name: Print Logs
+# if: failure()
+# run: ./scripts/print_build_logs.sh
diff --git a/.github/workflows/e2e_firebase_android_test.yml b/.github/workflows/e2e_firebase_android_test.yml
new file mode 100644
index 000000000..b01957fc3
--- /dev/null
+++ b/.github/workflows/e2e_firebase_android_test.yml
@@ -0,0 +1,63 @@
+name: e2e-firebase-android
+
+on:
+ push:
+# branches: [ master ]
+
+# Triggers only when authCompose module changes
+# paths:
+# - 'authCompose/**'
+# - 'build.gradle'
+# - 'settings.gradle'
+
+# pull_request:
+# branches: [ master ]
+
+# Triggers only when authCompose module changes
+# paths:
+# - 'authCompose/**'
+# - 'build.gradle'
+# - 'settings.gradle'
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Cache Gradle packages
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+
+ - name: Firebase Emulator Cache
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/firebase/emulators
+ key: firebase-emulators-v3-${{ runner.os }}
+
+ - name: Install Node.js 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ - name: Install Tools
+ run: |
+ npm i -g firebase-tools
+
+ - name: Start Firebase Auth Emulator Only
+ run: ./.github/workflows/scripts/start-firebase-emulator.sh
+
+ - name: Run tests for authCompose
+ run: ./gradlew testRobo
\ No newline at end of file
diff --git a/.github/workflows/scripts/start-firebase-emulator.sh b/.github/workflows/scripts/start-firebase-emulator.sh
new file mode 100755
index 000000000..3466af9fb
--- /dev/null
+++ b/.github/workflows/scripts/start-firebase-emulator.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+if ! [ -x "$(command -v firebase)" ]; then
+ echo "❌ Firebase tools CLI is missing."
+ exit 1
+fi
+
+if ! [ -x "$(command -v node)" ]; then
+ echo "❌ Node.js is missing."
+ exit 1
+fi
+
+if ! [ -x "$(command -v npm)" ]; then
+ echo "❌ NPM is missing."
+ exit 1
+fi
+
+# Extract project ID from .firebaserc
+export FIREBASE_PROJECT_ID=$(cat authCompose/.firebaserc | jq -r '.projects.default')
+
+# Extract auth port from firebase.json
+AUTH_PORT=$(cat authCompose/firebase.json | jq -r '.emulators.auth.port')
+export FIREBASE_AUTH_EMULATOR_URL="http://127.0.0.1:${AUTH_PORT}"
+
+# Starts firebase auth emulator only
+EMU_START_COMMAND="firebase emulators:start --only auth --project ${FIREBASE_PROJECT_ID}"
+
+MAX_RETRIES=3
+MAX_CHECKATTEMPTS=60
+CHECKATTEMPTS_WAIT=1
+
+RETRIES=1
+while [ $RETRIES -le $MAX_RETRIES ]; do
+
+ if [[ -z "${CI}" ]]; then
+ echo "Starting Firebase Emulator Suite in foreground."
+ $EMU_START_COMMAND
+ exit 0
+ else
+ echo "Starting Firebase Emulator Suite in background."
+ $EMU_START_COMMAND &
+ CHECKATTEMPTS=1
+ while [ $CHECKATTEMPTS -le $MAX_CHECKATTEMPTS ]; do
+ sleep $CHECKATTEMPTS_WAIT
+ if curl --output /dev/null --silent --fail ${FIREBASE_AUTH_EMULATOR_URL}; then
+ # Check again since it can exit before the emulator is ready.
+ sleep 15
+ if curl --output /dev/null --silent --fail ${FIREBASE_AUTH_EMULATOR_URL}; then
+ echo "Firebase Emulator Suite is online!"
+ exit 0
+ else
+ echo "❌ Firebase Emulator exited after startup."
+ exit 1
+ fi
+ fi
+ echo "Waiting for Firebase Emulator Suite to come online, check $CHECKATTEMPTS of $MAX_CHECKATTEMPTS..."
+ ((CHECKATTEMPTS = CHECKATTEMPTS + 1))
+ done
+ fi
+
+ echo "Firebase Emulator Suite did not come online in $MAX_CHECKATTEMPTS checks. Try $RETRIES of $MAX_RETRIES."
+ ((RETRIES = RETRIES + 1))
+
+done
+echo "Firebase Emulator Suite did not come online after $MAX_RETRIES attempts."
+exit 1
diff --git a/appcompose/.gitignore b/appcompose/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/appcompose/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/appcompose/build.gradle.kts b/appcompose/build.gradle.kts
new file mode 100644
index 000000000..5ebd25d43
--- /dev/null
+++ b/appcompose/build.gradle.kts
@@ -0,0 +1,73 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"
+ alias(libs.plugins.jetbrains.kotlin.serialization)
+}
+
+android {
+ namespace = "com.firebase.ui.appcompose"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.firebase.ui.appcompose"
+ minSdk = 23
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+
+ // Local module dependency
+ implementation(project(":authCompose"))
+
+ // Navigation
+ implementation(libs.androidx.navigation3.ui)
+ implementation(libs.androidx.navigation3.runtime)
+ implementation(libs.androidx.lifecycle.viewmodel.navigation3)
+ implementation(libs.androidx.material3.adaptive.navigation3)
+ implementation(libs.kotlinx.serialization.core)
+}
+
+// Enable Google Services plugin to process google-services.json for Firebase init
+apply(plugin = "com.google.gms.google-services")
diff --git a/appcompose/proguard-rules.pro b/appcompose/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/appcompose/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/appcompose/src/androidTest/java/com/firebase/ui/appcompose/ExampleInstrumentedTest.kt b/appcompose/src/androidTest/java/com/firebase/ui/appcompose/ExampleInstrumentedTest.kt
new file mode 100644
index 000000000..5dcea3770
--- /dev/null
+++ b/appcompose/src/androidTest/java/com/firebase/ui/appcompose/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.firebase.ui.appcompose
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.firebase.ui.appcompose", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/appcompose/src/main/AndroidManifest.xml b/appcompose/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..b82d59b3d
--- /dev/null
+++ b/appcompose/src/main/AndroidManifest.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/appcompose/src/main/java/com/firebase/ui/appcompose/MainActivity.kt b/appcompose/src/main/java/com/firebase/ui/appcompose/MainActivity.kt
new file mode 100644
index 000000000..5fc0d87db
--- /dev/null
+++ b/appcompose/src/main/java/com/firebase/ui/appcompose/MainActivity.kt
@@ -0,0 +1,35 @@
+package com.firebase.ui.appcompose
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import com.firebase.ui.authcompose.core.AuthUIConfig
+import com.firebase.ui.authcompose.core.AuthUIProvider
+import com.firebase.ui.appcompose.app.App
+import com.firebase.ui.appcompose.ui.theme.FirebaseUIAndroidTheme
+import com.firebase.ui.authcompose.FirebaseAuthUI
+import com.google.firebase.FirebaseApp
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ FirebaseAuthUI.initialize(
+ app = FirebaseApp.getInstance(),
+ config = AuthUIConfig(
+ providers = listOf(
+ // AuthUIProvider.Google(),
+ AuthUIProvider.Google(clientId = "771411398215-o39fujhds88bs4mb5ai7u6o73g86fspp.apps.googleusercontent.com"),
+ // AuthUIProvider.Email(enableEmailLinkSignIn = true),
+ AuthUIProvider.Email()
+ )
+ )
+ )
+ enableEdgeToEdge()
+ setContent {
+ FirebaseUIAndroidTheme {
+ App()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/appcompose/src/main/java/com/firebase/ui/appcompose/app/App.kt b/appcompose/src/main/java/com/firebase/ui/appcompose/app/App.kt
new file mode 100644
index 000000000..e1ae2e422
--- /dev/null
+++ b/appcompose/src/main/java/com/firebase/ui/appcompose/app/App.kt
@@ -0,0 +1,62 @@
+package com.firebase.ui.appcompose.app
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
+import androidx.compose.runtime.Composable
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.firebase.ui.appcompose.router.Route
+import com.firebase.ui.appcompose.router.Router
+import com.firebase.ui.appcompose.ui.views.AuthView
+import com.firebase.ui.appcompose.ui.views.HomeView
+import com.firebase.ui.appcompose.ui.views.MainView
+import com.firebase.ui.appcompose.ui.views.UnknownView
+
+@Composable
+fun App() {
+ val backStack = rememberNavBackStack(Route.Initial)
+ val router = Router(backStack)
+
+ NavDisplay(
+ backStack = router.backStack,
+ transitionSpec = {
+ // Slide in from right when navigating forward
+ slideInHorizontally(initialOffsetX = { it }) togetherWith
+ slideOutHorizontally(targetOffsetX = { -it })
+ },
+ popTransitionSpec = {
+ // Slide in from left when navigating back
+ slideInHorizontally(initialOffsetX = { -it }) togetherWith
+ slideOutHorizontally(targetOffsetX = { it })
+ },
+ onBack = {
+ if (router.backStack.size > 1) {
+ router.pop()
+ }
+ },
+ entryProvider = { entry ->
+ val route = entry as Route
+
+ when (route) {
+ is Route.Initial -> NavEntry(route) {
+ MainView(router)
+ }
+
+ is Route.Auth -> NavEntry(route) {
+ AuthView(router)
+ }
+
+ is Route.Home -> NavEntry(route) {
+ HomeView(router, currentUserName = route.currentUserName)
+ }
+
+ is Route.Unknown -> NavEntry(route) {
+ UnknownView()
+ }
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/appcompose/src/main/java/com/firebase/ui/appcompose/router/Router.kt b/appcompose/src/main/java/com/firebase/ui/appcompose/router/Router.kt
new file mode 100644
index 000000000..2145d4f0e
--- /dev/null
+++ b/appcompose/src/main/java/com/firebase/ui/appcompose/router/Router.kt
@@ -0,0 +1,35 @@
+package com.firebase.ui.appcompose.router
+
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed class Route(val path: String) : NavKey {
+ @Serializable
+ object Initial : Route(path = "/")
+
+ @Serializable
+ object Auth : Route(path = "/auth")
+
+ @Serializable
+ class Home(val currentUserName: String) : Route(path = "/home")
+
+ @Serializable
+ object Unknown : Route(path = "/unknown")
+}
+
+class Router(val backStack: NavBackStack) {
+ fun push(route: Route) {
+ backStack.add(route)
+ }
+
+ fun pushAndRemoveUntil(route: Route) {
+ backStack.clear()
+ backStack.add(route)
+ }
+
+ fun pop() {
+ backStack.removeLastOrNull()
+ }
+}
diff --git a/appcompose/src/main/java/com/firebase/ui/appcompose/ui/theme/Color.kt b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/theme/Color.kt
new file mode 100644
index 000000000..1f9ec920e
--- /dev/null
+++ b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.firebase.ui.appcompose.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/appcompose/src/main/java/com/firebase/ui/appcompose/ui/theme/Theme.kt b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/theme/Theme.kt
new file mode 100644
index 000000000..3dda90b67
--- /dev/null
+++ b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/theme/Theme.kt
@@ -0,0 +1,58 @@
+package com.firebase.ui.appcompose.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.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun FirebaseUIAndroidTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ 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
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/appcompose/src/main/java/com/firebase/ui/appcompose/ui/theme/Type.kt b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/theme/Type.kt
new file mode 100644
index 000000000..97d0f262d
--- /dev/null
+++ b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package com.firebase.ui.appcompose.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
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ 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/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/AuthView.kt b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/AuthView.kt
new file mode 100644
index 000000000..0fc5dd151
--- /dev/null
+++ b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/AuthView.kt
@@ -0,0 +1,55 @@
+package com.firebase.ui.appcompose.ui.views
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.authcompose.core.AuthUIResult
+import com.firebase.ui.appcompose.router.Route
+import com.firebase.ui.appcompose.router.Route.*
+import com.firebase.ui.appcompose.router.Router
+import com.firebase.ui.authcompose.FirebaseAuthUI
+import com.firebase.ui.authcompose.FirebaseAuthScreen
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AuthView(router: Router) {
+ FirebaseAuthScreen(
+ authUI = FirebaseAuthUI.instance,
+ onResult = { result ->
+ when (result) {
+ is AuthUIResult.Success -> {
+ router.pushAndRemoveUntil(
+ Home(
+ currentUserName = result.user.displayName ?: result.user.email
+ ?: "User"
+ )
+ )
+ println("✅Sign-in successful: ${result.user.displayName}")
+ }
+
+ is AuthUIResult.Failure -> {
+ println("❌ Sign-in failed: ${result.exception.message}")
+ }
+
+ is AuthUIResult.Cancelled -> {
+ println("🚫 Sign-in cancelled")
+ }
+
+ null -> {
+
+ }
+ }
+ },
+ canPop = true,
+ onDismiss = {
+ router.pop()
+ }
+ )
+}
\ No newline at end of file
diff --git a/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/HomeView.kt b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/HomeView.kt
new file mode 100644
index 000000000..c36837034
--- /dev/null
+++ b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/HomeView.kt
@@ -0,0 +1,40 @@
+package com.firebase.ui.appcompose.ui.views
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.firebase.ui.appcompose.router.Route
+import com.firebase.ui.appcompose.router.Router
+import com.firebase.ui.authcompose.FirebaseAuthUI
+
+@Composable
+fun HomeView(router: Router, currentUserName: String) {
+ Scaffold(
+ modifier = Modifier.fillMaxSize()
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text("Welcome, $currentUserName")
+ Button(
+ onClick = {
+ FirebaseAuthUI.instance.signOut()
+ router.pushAndRemoveUntil(Route.Initial)
+ }
+ ) {
+ Text("Sign Out")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/MainView.kt b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/MainView.kt
new file mode 100644
index 000000000..9dc073c00
--- /dev/null
+++ b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/MainView.kt
@@ -0,0 +1,77 @@
+package com.firebase.ui.appcompose.ui.views
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.navigation3.runtime.rememberNavBackStack
+import com.firebase.ui.appcompose.router.Route
+import com.firebase.ui.appcompose.router.Router
+import com.firebase.ui.appcompose.ui.theme.FirebaseUIAndroidTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MainView(router: Router) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text("Auth UI Compose Demo")
+ },
+ navigationIcon = {
+ if (router.backStack.size > 1) {
+ IconButton(
+ onClick = {
+ router.pop()
+ }
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ }
+ }
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Button(
+ onClick = {
+ router.push(Route.Auth)
+ }
+ ) {
+ Text("Go to Auth")
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewMainView() {
+ val backStack = rememberNavBackStack(Route.Initial)
+
+ FirebaseUIAndroidTheme {
+ MainView(router = Router(backStack = backStack))
+ }
+}
diff --git a/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/UnknownView.kt b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/UnknownView.kt
new file mode 100644
index 000000000..8a2f09703
--- /dev/null
+++ b/appcompose/src/main/java/com/firebase/ui/appcompose/ui/views/UnknownView.kt
@@ -0,0 +1,9 @@
+package com.firebase.ui.appcompose.ui.views
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+
+@Composable
+fun UnknownView() {
+ Text("404 Not Found")
+}
\ No newline at end of file
diff --git a/appcompose/src/main/res/drawable-v24/ic_launcher_foreground.xml b/appcompose/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 000000000..fde1368fc
--- /dev/null
+++ b/appcompose/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/appcompose/src/main/res/drawable/ic_launcher_background.xml b/appcompose/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 000000000..1e4408cae
--- /dev/null
+++ b/appcompose/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/appcompose/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/appcompose/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..6f3b755bf
--- /dev/null
+++ b/appcompose/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/appcompose/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/appcompose/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..6f3b755bf
--- /dev/null
+++ b/appcompose/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/appcompose/src/main/res/mipmap-hdpi/ic_launcher.webp b/appcompose/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 000000000..c209e78ec
Binary files /dev/null and b/appcompose/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/appcompose/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/appcompose/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..b2dfe3d1b
Binary files /dev/null and b/appcompose/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/appcompose/src/main/res/mipmap-mdpi/ic_launcher.webp b/appcompose/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 000000000..4f0f1d64e
Binary files /dev/null and b/appcompose/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/appcompose/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/appcompose/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..62b611da0
Binary files /dev/null and b/appcompose/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/appcompose/src/main/res/mipmap-xhdpi/ic_launcher.webp b/appcompose/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 000000000..948a3070f
Binary files /dev/null and b/appcompose/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/appcompose/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/appcompose/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..1b9a6956b
Binary files /dev/null and b/appcompose/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/appcompose/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/appcompose/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..28d4b77f9
Binary files /dev/null and b/appcompose/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/appcompose/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/appcompose/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9287f5083
Binary files /dev/null and b/appcompose/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/appcompose/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/appcompose/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..aa7d6427e
Binary files /dev/null and b/appcompose/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/appcompose/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/appcompose/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9126ae37c
Binary files /dev/null and b/appcompose/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/appcompose/src/main/res/values/colors.xml b/appcompose/src/main/res/values/colors.xml
new file mode 100644
index 000000000..f8c6127d3
--- /dev/null
+++ b/appcompose/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/appcompose/src/main/res/values/strings.xml b/appcompose/src/main/res/values/strings.xml
new file mode 100644
index 000000000..83143172a
--- /dev/null
+++ b/appcompose/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ appCompose
+
\ No newline at end of file
diff --git a/appcompose/src/main/res/values/themes.xml b/appcompose/src/main/res/values/themes.xml
new file mode 100644
index 000000000..1f225670b
--- /dev/null
+++ b/appcompose/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/appcompose/src/test/java/com/firebase/ui/appcompose/ExampleUnitTest.kt b/appcompose/src/test/java/com/firebase/ui/appcompose/ExampleUnitTest.kt
new file mode 100644
index 000000000..3382aced6
--- /dev/null
+++ b/appcompose/src/test/java/com/firebase/ui/appcompose/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.firebase.ui.appcompose
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/res/values/config.xml b/auth/src/main/res/values/config.xml
index ec03e4b3c..b81bbb2e6 100644
--- a/auth/src/main/res/values/config.xml
+++ b/auth/src/main/res/values/config.xml
@@ -29,5 +29,5 @@
https://developers.google.com/identity/sign-in/web/devconsole-project
https://developers.google.com/android/guides/google-services-plugin
-->
- CHANGE-ME
+ 771411398215-o39fujhds88bs4mb5ai7u6o73g86fspp.apps.googleusercontent.com
\ No newline at end of file
diff --git a/authCompose/.firebaserc b/authCompose/.firebaserc
new file mode 100644
index 000000000..43d6ab829
--- /dev/null
+++ b/authCompose/.firebaserc
@@ -0,0 +1,5 @@
+{
+ "projects": {
+ "default": "temp-test-aa342"
+ }
+}
diff --git a/authCompose/.gitignore b/authCompose/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/authCompose/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/authCompose/build.gradle.kts b/authCompose/build.gradle.kts
new file mode 100644
index 000000000..e88ceb062
--- /dev/null
+++ b/authCompose/build.gradle.kts
@@ -0,0 +1,125 @@
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"
+ alias(libs.plugins.jetbrains.kotlin.serialization)
+}
+
+android {
+ namespace = "com.firebase.ui.authcompose"
+ compileSdk = 36
+
+ defaultConfig {
+ minSdk = 23
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.8"
+ }
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ testImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+
+ // Additional Dependencies
+ implementation(platform(Config.Libs.Firebase.bom))
+ api(Config.Libs.Firebase.auth)
+
+ // Credential Manager for modern Google Sign-In
+ implementation(libs.androidx.credentials)
+ implementation(libs.androidx.credentials.play.services.auth)
+ implementation(libs.googleid)
+
+ // Navigation
+ implementation(libs.androidx.navigation3.ui)
+ implementation(libs.androidx.navigation3.runtime)
+ implementation(libs.androidx.lifecycle.viewmodel.navigation3)
+ implementation(libs.androidx.material3.adaptive.navigation3)
+ implementation(libs.kotlinx.serialization.core)
+
+ // Testing
+ testImplementation(libs.robolectric)
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.mockito)
+ testImplementation(libs.mockito.inline)
+ testImplementation(libs.mockito.kotlin)
+ testImplementation(libs.androidx.credentials)
+ testImplementation(libs.truth)
+}
+
+val mockitoAgent by configurations.creating
+
+dependencies {
+ testImplementation(libs.mockito)
+ mockitoAgent(libs.mockito) {
+ isTransitive = false
+ }
+}
+
+tasks.withType().configureEach {
+ jvmArgs("-javaagent:${mockitoAgent.asPath}")
+}
+
+tasks.register("testRobo") {
+ description = "Runs Robolectric debug unit tests"
+ group = "verification"
+
+ val debug = tasks.named("testDebugUnitTest").get()
+ testClassesDirs = debug.testClassesDirs
+ classpath = debug.classpath
+
+ // Always rerun (like Studio)
+ doNotTrackState("Always run Robolectric tests to mirror Studio")
+
+ // Force Robolectric to use API 36
+ systemProperty("robolectric.enabledSdks", "36")
+
+ // Use JDK 21 only for Robolectric
+ javaLauncher.set(
+ javaToolchains.launcherFor {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ }
+ )
+}
diff --git a/authCompose/consumer-rules.pro b/authCompose/consumer-rules.pro
new file mode 100644
index 000000000..e69de29bb
diff --git a/authCompose/firebase.json b/authCompose/firebase.json
new file mode 100644
index 000000000..2fb2a16b0
--- /dev/null
+++ b/authCompose/firebase.json
@@ -0,0 +1,11 @@
+{
+ "emulators": {
+ "auth": {
+ "port": 9099
+ },
+ "ui": {
+ "enabled": true
+ },
+ "singleProjectMode": true
+ }
+}
diff --git a/authCompose/proguard-rules.pro b/authCompose/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/authCompose/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/authCompose/src/main/AndroidManifest.xml b/authCompose/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..a5918e68a
--- /dev/null
+++ b/authCompose/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/AuthMethodPicker.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/AuthMethodPicker.kt
new file mode 100644
index 000000000..2bcec53e6
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/AuthMethodPicker.kt
@@ -0,0 +1,85 @@
+package com.firebase.ui.authcompose
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.authcompose.core.AuthUIProvider
+import com.firebase.ui.authcompose.core.Router
+import com.firebase.ui.authcompose.ui.button.EmailSignInButton
+import com.firebase.ui.authcompose.ui.button.GoogleSignInButton
+import com.firebase.ui.authcompose.ui.button.PhoneSignInButton
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun AuthMethodPicker(
+ router: Router,
+ providers: List,
+ canPop: Boolean,
+ onDismiss: (() -> Unit)? = null,
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text("Sign In")
+ },
+ navigationIcon = {
+ if (canPop) {
+ IconButton(
+ onClick = {
+ onDismiss?.invoke()
+ }
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ }
+ }
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .padding(horizontal = 16.dp)
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.Bottom,
+ ) {
+ providers.forEach { provider ->
+ when (provider) {
+ is AuthUIProvider.Google -> {
+ GoogleSignInButton(provider)
+ }
+
+ is AuthUIProvider.Email -> {
+ EmailSignInButton(
+ router,
+ provider = provider,
+ )
+ }
+
+ is AuthUIProvider.Phone -> {
+ PhoneSignInButton(provider)
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+ }
+}
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/AuthViewModel.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/AuthViewModel.kt
new file mode 100644
index 000000000..4f89bd6fd
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/AuthViewModel.kt
@@ -0,0 +1,139 @@
+package com.firebase.ui.authcompose
+
+import android.content.Context
+import androidx.credentials.CredentialManager
+import androidx.credentials.GetCredentialRequest
+import androidx.credentials.exceptions.GetCredentialCancellationException
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.firebase.ui.authcompose.core.AuthUIProvider
+import com.firebase.ui.authcompose.core.AuthUIResult
+import com.google.android.libraries.identity.googleid.GetGoogleIdOption
+import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.GoogleAuthProvider
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+sealed class AuthState {
+ object Idle : AuthState()
+ data class Loading(val message: String? = null) : AuthState()
+ data class Success(val user: FirebaseUser) : AuthState()
+ data class Error(val exception: Exception) : AuthState()
+ object Cancelled : AuthState()
+}
+
+class AuthViewModel(
+ private val credentialManager: CredentialManager,
+ private val firebaseAuth: FirebaseAuth,
+ private val onResult: ((AuthState) -> Unit)? = null
+) : ViewModel() {
+ private val _state = MutableStateFlow(AuthState.Idle)
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ state
+ .onEach { newAuthState -> onResult?.invoke(newAuthState) }
+ .launchIn(viewModelScope)
+ }
+
+ private fun updateState(newState: AuthState) {
+ if (newState != state.value) {
+ _state.update {
+ newState
+ }
+ }
+ }
+
+ suspend fun signInWithGoogle(context: Context, provider: AuthUIProvider.Google) {
+ updateState(AuthState.Loading())
+ try {
+ val serverClientId =
+ provider.clientId ?: throw IllegalStateException("Server client ID not found")
+
+ val googleIdOption = GetGoogleIdOption.Builder()
+ .setServerClientId(serverClientId)
+ .setFilterByAuthorizedAccounts(false)
+ .setAutoSelectEnabled(false)
+ .build()
+
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(googleIdOption)
+ .build()
+
+ val credentialResult = credentialManager.getCredential(context, request)
+
+ val googleIdTokenCredential =
+ GoogleIdTokenCredential.createFrom(credentialResult.credential.data)
+
+ val firebaseCredential =
+ GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null)
+ val authResult =
+ firebaseAuth.signInWithCredential((firebaseCredential)).await()
+
+ authResult.user?.let { user ->
+ updateState(AuthState.Success(user))
+ } ?: run {
+ updateState(AuthState.Error(Exception("Sign in failed: no user returned")))
+ }
+ } catch (_: GetCredentialCancellationException) {
+ updateState(AuthState.Cancelled)
+ } catch (e: GetCredentialException) {
+ updateState(AuthState.Error(e))
+ } catch (e: Exception) {
+ updateState(AuthState.Error(e))
+ } catch (e: Exception) {
+ updateState(AuthState.Error(e))
+ }
+ }
+
+ suspend fun signInWithEmailAndPassword(
+ email: String,
+ password: String
+ ) {
+ updateState(AuthState.Loading())
+
+ // TODO: handle canUpgradeAnonymous
+ try {
+ val authResult = firebaseAuth.signInWithEmailAndPassword(email, password)
+ .await()
+ authResult.user?.let { user ->
+ updateState(AuthState.Success(user))
+ } ?: run {
+ updateState(AuthState.Error(Exception("Sign in failed: no user returned")))
+ }
+ } catch (e: Exception) {
+ updateState(AuthState.Error(e))
+ }
+ }
+
+ suspend fun createOrLinkUserWithEmailAndPassword(
+ name: String,
+ email: String,
+ password: String
+ ) {
+ updateState(AuthState.Loading())
+
+ // TODO: handle canUpgradeAnonymous
+ // TODO: handle Profile merging logic with User object e.g. name
+ try {
+ val authResult = firebaseAuth.createUserWithEmailAndPassword(email, password)
+ .await()
+ authResult.user?.let { user ->
+ updateState(AuthState.Success(user))
+ } ?: run {
+ AuthUIResult.Failure(Exception("Sign in failed: no user returned"))
+ }
+ } catch (e: Exception) {
+ updateState(AuthState.Error(e))
+ }
+ }
+}
\ No newline at end of file
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/FirebaseAuthScreen.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/FirebaseAuthScreen.kt
new file mode 100644
index 000000000..167a98d14
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/FirebaseAuthScreen.kt
@@ -0,0 +1,162 @@
+package com.firebase.ui.authcompose
+
+import androidx.credentials.CredentialManager
+import android.util.Log
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.firebase.ui.authcompose.AuthState
+import com.firebase.ui.authcompose.core.AuthUIProvider
+import com.firebase.ui.authcompose.core.AuthUIResult
+import com.firebase.ui.authcompose.core.EmailAuthSignInHandler
+import com.firebase.ui.authcompose.core.Route
+import com.firebase.ui.authcompose.core.Router
+import com.firebase.ui.authcompose.core.toAuthUIResult
+import com.firebase.ui.authcompose.di.AppModule
+import com.firebase.ui.authcompose.ui.email.EmailAuthExistingUserScreen
+import com.firebase.ui.authcompose.ui.email.EmailAuthPasswordScreen
+import com.firebase.ui.authcompose.ui.email.EmailAuthSignupScreen
+import com.firebase.ui.authcompose.ui.email.EmailAuthScreen
+import kotlinx.coroutines.flow.onSubscription
+import kotlinx.coroutines.launch
+
+val LocalAppModule = staticCompositionLocalOf {
+ error("AppModule not provided")
+}
+
+@Composable
+fun FirebaseAuthScreen(
+ authUI: FirebaseAuthUI,
+ onResult: (AuthUIResult?) -> Unit,
+ canPop: Boolean = false,
+ onDismiss: (() -> Unit)? = null,
+) {
+ val context = LocalContext.current
+
+ val credentialManager = remember { CredentialManager.create(context) }
+
+ val authViewModel: AuthViewModel = viewModel(
+ factory = viewModelFactory {
+ initializer {
+ AuthViewModel(
+ credentialManager = credentialManager,
+ firebaseAuth = authUI.firebaseAuth
+ ) { authState ->
+ onResult(authState.toAuthUIResult())
+ }
+ }
+ }
+ )
+
+ val appModule: AppModule =
+ remember { AppModule(authViewModel = authViewModel) }
+
+ val backStack = rememberNavBackStack(Route.Initial)
+ val router = Router(backStack)
+
+ // Testing out LaunchedEffect as replacement for onResult in AuthViewModel but buggy
+ // val authState = authViewModel.state
+ //
+ // LaunchedEffect(authState) {
+ // authState.collect { newAuthState ->
+ // onResult(newAuthState.toAuthUIResult())
+ // }
+ // }
+
+ App(
+ authUI = authUI,
+ canPop = canPop,
+ onDismiss = onDismiss,
+ appModule = appModule,
+ router = router,
+ )
+}
+
+@Composable
+internal fun App(
+ authUI: FirebaseAuthUI,
+ canPop: Boolean = false,
+ onDismiss: (() -> Unit)? = null,
+ appModule: AppModule,
+ router: Router,
+) {
+ CompositionLocalProvider(LocalAppModule provides appModule) {
+ NavDisplay(
+ backStack = router.backStack,
+ transitionSpec = {
+ // Slide in from right when navigating forward
+ slideInHorizontally(initialOffsetX = { it }) togetherWith
+ slideOutHorizontally(targetOffsetX = { -it })
+ },
+ popTransitionSpec = {
+ // Slide in from left when navigating back
+ slideInHorizontally(initialOffsetX = { -it }) togetherWith
+ slideOutHorizontally(targetOffsetX = { it })
+ },
+ onBack = {
+ if (router.backStack.size > 1) {
+ router.pop()
+ }
+ },
+ entryProvider = { entry ->
+ val route = entry as Route
+
+ when (route) {
+ is Route.Initial -> NavEntry(route) {
+ AuthMethodPicker(
+ router = router,
+ providers = authUI.config.providers,
+ canPop = canPop,
+ onDismiss = onDismiss,
+ )
+ }
+
+ is Route.EmailAuth -> NavEntry(route) {
+ EmailAuthScreen(
+ router,
+ provider = route.provider
+ )
+ }
+
+ is Route.EmailAuth.Password -> NavEntry(route) {
+ EmailAuthPasswordScreen(
+ router,
+ email = route.email
+ )
+ }
+
+ is Route.EmailAuth.Signup -> NavEntry(route) {
+ EmailAuthSignupScreen(
+ router,
+ email = route.email,
+ )
+ }
+
+ is Route.EmailAuth.ExistingUser -> NavEntry(route) {
+ EmailAuthExistingUserScreen(existingUserEmail = route.existingUserEmail)
+ }
+ }
+ }
+ )
+ }
+}
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/FirebaseAuthUI.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/FirebaseAuthUI.kt
new file mode 100644
index 000000000..2e916e615
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/FirebaseAuthUI.kt
@@ -0,0 +1,80 @@
+package com.firebase.ui.authcompose
+
+import com.google.firebase.FirebaseApp
+import com.firebase.ui.authcompose.core.AuthUIConfig
+import com.google.firebase.auth.FirebaseAuth
+
+class FirebaseAuthUI private constructor(
+ internal val app: FirebaseApp,
+ internal val firebaseAuth: FirebaseAuth,
+ internal val config: AuthUIConfig
+) {
+ companion object {
+ private object Singleton {
+ private val instances = mutableMapOf()
+
+ @Synchronized
+ fun getInstance(app: FirebaseApp): FirebaseAuthUI {
+ return instances[app] ?: throw IllegalStateException(
+ "AuthUI not initialized for app ${app.name}. Call AuthUI.initialize() first."
+ )
+ }
+
+ @Synchronized
+ fun setInstance(app: FirebaseApp, firebaseAuthUI: FirebaseAuthUI) {
+ instances[app] = firebaseAuthUI
+ }
+ }
+
+ val instance: FirebaseAuthUI
+ get() = Singleton.getInstance(FirebaseApp.getInstance())
+
+ fun getInstance(app: FirebaseApp): FirebaseAuthUI {
+ return Singleton.getInstance(app)
+ }
+
+ fun initialize(app: FirebaseApp, config: AuthUIConfig): FirebaseAuthUI {
+ val firebaseAuthUI =
+ FirebaseAuthUI(
+ app = app,
+ firebaseAuth = FirebaseAuth.getInstance(app),
+ config = config
+ )
+ Singleton.setInstance(app, firebaseAuthUI)
+ return firebaseAuthUI
+ }
+ }
+
+ fun signOut() {
+ firebaseAuth.signOut()
+ }
+}
+
+//class FirebaseAuthUI(val app: FirebaseApp, val auth: FirebaseAuth) {
+// companion object {
+// private val instances = mutableMapOf()
+//
+// val instance: FirebaseAuthUI
+// get() = getInstance(FirebaseApp.getInstance())
+//
+// fun create(app: FirebaseApp, auth: FirebaseAuthUI): FirebaseAuthUI {
+// return getInstance(FirebaseApp.getInstance())
+// }
+//
+// @Synchronized
+// fun getInstance(app: FirebaseApp): FirebaseAuthUI {
+// return instances.getOrPut(app) {
+// FirebaseAuthUI(
+// app = app,
+// auth = FirebaseAuth.getInstance(app),
+// )
+// }
+// }
+// }
+//
+// fun getCurrentUser(): FirebaseUser? = auth.currentUser
+//
+// fun isSignedIn(): Boolean = auth.currentUser != null
+//
+// fun signOut() = auth.signOut()
+//}
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/core/AuthUIConfig.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/core/AuthUIConfig.kt
new file mode 100644
index 000000000..87292e079
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/core/AuthUIConfig.kt
@@ -0,0 +1,3 @@
+package com.firebase.ui.authcompose.core
+
+data class AuthUIConfig(val providers: List)
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/core/AuthUIProvider.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/core/AuthUIProvider.kt
new file mode 100644
index 000000000..459780e35
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/core/AuthUIProvider.kt
@@ -0,0 +1,31 @@
+package com.firebase.ui.authcompose.core
+
+import com.google.firebase.auth.EmailAuthProvider
+import com.google.firebase.auth.GoogleAuthProvider
+import com.google.firebase.auth.PhoneAuthProvider
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed class AuthUIProvider(
+ val providerId: String,
+ val params: Map = emptyMap()
+) {
+ @Serializable
+ data class Google(
+ val clientId: String? = null,
+ val scopes: List = listOf(),
+ ) : AuthUIProvider(providerId = GoogleAuthProvider.PROVIDER_ID)
+
+ @Serializable
+ data class Email(
+ val requireDisplayName: Boolean = false,
+ val allowNewAccounts: Boolean = false,
+ val enableEmailLinkSignIn: Boolean = false,
+ ) : AuthUIProvider(providerId = EmailAuthProvider.PROVIDER_ID)
+ @Serializable
+ data class Phone(
+ val countryIso: String? = null,
+ val phoneNumber: String? = null,
+ ) : AuthUIProvider(providerId = PhoneAuthProvider.PROVIDER_ID)
+}
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/core/AuthUIResult.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/core/AuthUIResult.kt
new file mode 100644
index 000000000..9fe3e6987
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/core/AuthUIResult.kt
@@ -0,0 +1,19 @@
+package com.firebase.ui.authcompose.core
+
+import com.firebase.ui.authcompose.AuthState
+import com.google.firebase.auth.FirebaseUser
+
+sealed class AuthUIResult {
+ data class Success(val user: FirebaseUser) : AuthUIResult()
+ data class Failure(val exception: Exception) : AuthUIResult()
+ object Cancelled : AuthUIResult()
+}
+
+fun AuthState.toAuthUIResult(): AuthUIResult? =
+ when (this) {
+ is AuthState.Success -> AuthUIResult.Success(user)
+ is AuthState.Error -> AuthUIResult.Failure(exception)
+ is AuthState.Cancelled -> AuthUIResult.Cancelled
+ is AuthState.Idle,
+ is AuthState.Loading -> null
+ }
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/core/EmailAuthSignInHandler.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/core/EmailAuthSignInHandler.kt
new file mode 100644
index 000000000..c17c8d52c
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/core/EmailAuthSignInHandler.kt
@@ -0,0 +1,9 @@
+package com.firebase.ui.authcompose.core
+
+import com.google.firebase.auth.FirebaseAuth
+import kotlinx.coroutines.tasks.await
+
+class EmailAuthSignInHandler {
+ companion object {
+ }
+}
\ No newline at end of file
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/core/Router.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/core/Router.kt
new file mode 100644
index 000000000..1bb132744
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/core/Router.kt
@@ -0,0 +1,37 @@
+package com.firebase.ui.authcompose.core
+
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal sealed class Route(val path: String) : NavKey {
+ @Serializable
+ object Initial : Route(path = "/")
+
+ @Serializable
+ class EmailAuth(val provider: AuthUIProvider.Email) : Route(path = PATH) {
+ companion object {
+ const val PATH = "/email-auth"
+ }
+
+ @Serializable
+ class Password(val email: String) : Route(path = "$PATH/password")
+
+ @Serializable
+ class Signup(val email: String) : Route(path = "$PATH/signup")
+
+ @Serializable
+ class ExistingUser(val existingUserEmail: String) : Route(path = "$PATH/existing-user")
+ }
+}
+
+internal class Router(val backStack: NavBackStack) {
+ fun push(route: Route) {
+ backStack.add(route)
+ }
+
+ fun pop() {
+ backStack.removeLastOrNull()
+ }
+}
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/di/AppModule.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/di/AppModule.kt
new file mode 100644
index 000000000..ce127267d
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/di/AppModule.kt
@@ -0,0 +1,10 @@
+package com.firebase.ui.authcompose.di
+
+import android.content.Context
+import androidx.compose.ui.platform.LocalContext
+import androidx.credentials.CredentialManager
+import com.firebase.ui.authcompose.AuthViewModel
+import com.firebase.ui.authcompose.core.AuthUIResult
+import com.google.firebase.auth.FirebaseAuth
+
+class AppModule(val authViewModel: AuthViewModel)
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/model/User.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/model/User.kt
new file mode 100644
index 000000000..093fbeaec
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/model/User.kt
@@ -0,0 +1,3 @@
+package com.firebase.ui.authcompose.model
+
+data class User(val displayName: String, val email: String)
\ No newline at end of file
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/ui/button/EmailSignInButton.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/button/EmailSignInButton.kt
new file mode 100644
index 000000000..58347f170
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/button/EmailSignInButton.kt
@@ -0,0 +1,45 @@
+package com.firebase.ui.authcompose.ui.button
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.authcompose.core.AuthUIProvider
+import com.firebase.ui.authcompose.R
+import com.firebase.ui.authcompose.core.Route
+import com.firebase.ui.authcompose.core.Router
+
+@Composable
+internal fun EmailSignInButton(
+ router: Router,
+ provider: AuthUIProvider.Email
+) {
+ Button(
+ onClick = {
+ router.push(Route.EmailAuth(provider = provider))
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.fui_ic_mail_white_24dp),
+ contentDescription = "email sign in logo",
+ modifier = Modifier
+ .size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Sign in with Email")
+ }
+ }
+}
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/ui/button/GoogleSignInButton.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/button/GoogleSignInButton.kt
new file mode 100644
index 000000000..59bbfb637
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/button/GoogleSignInButton.kt
@@ -0,0 +1,92 @@
+package com.firebase.ui.authcompose.ui.button
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import androidx.credentials.exceptions.GetCredentialCancellationException
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.lifecycle.viewModelScope
+import com.firebase.ui.authcompose.AuthState
+import com.firebase.ui.authcompose.LocalAppModule
+import com.firebase.ui.authcompose.core.AuthUIProvider
+import com.firebase.ui.authcompose.R
+import com.google.firebase.auth.FirebaseUser
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun GoogleSignInButton(provider: AuthUIProvider.Google) {
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ val viewModel = LocalAppModule.current.authViewModel
+ val authState by viewModel.state.collectAsState()
+
+ val signInTextRes = stringResource(R.string.fui_sign_in_with_google)
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
+ tooltip = {
+ PlainTooltip {
+ Text("This button is currently disabled because clientId is missing")
+ }
+ },
+ state = rememberTooltipState(
+ initialIsVisible = provider.clientId == null
+ )
+ ) {
+ Button(
+ onClick = {
+ coroutineScope.launch {
+ viewModel.signInWithGoogle(context, provider = provider)
+ }
+ },
+ enabled = provider.clientId != null && authState !is AuthState.Loading,
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics {
+ contentDescription = signInTextRes
+ },
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (authState is AuthState.Loading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp)
+ )
+ } else {
+ Icon(
+ painter = painterResource(R.drawable.fui_ic_googleg_color_24dp),
+ contentDescription = "google sign in logo",
+ modifier = Modifier
+ .size(18.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(signInTextRes)
+ }
+ }
+ }
+}
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/ui/button/PhoneSignInButton.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/button/PhoneSignInButton.kt
new file mode 100644
index 000000000..7280c0eef
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/button/PhoneSignInButton.kt
@@ -0,0 +1,9 @@
+package com.firebase.ui.authcompose.ui.button
+
+import androidx.compose.runtime.Composable
+import com.firebase.ui.authcompose.core.AuthUIProvider
+
+@Composable
+internal fun PhoneSignInButton(provider: AuthUIProvider.Phone) {
+
+}
\ No newline at end of file
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthExistingUserScreen.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthExistingUserScreen.kt
new file mode 100644
index 000000000..f23e5d9c0
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthExistingUserScreen.kt
@@ -0,0 +1,37 @@
+package com.firebase.ui.authcompose.ui.email
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+internal fun EmailAuthExistingUserScreen(existingUserEmail: String) {
+ val passwordTextValue = remember { mutableStateOf("") }
+ Column {
+ Text("Welcome back")
+ Text(
+ "You've already used $existingUserEmail to sign in. " +
+ "Enter your password for that account."
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ TextField(
+ value = passwordTextValue.value,
+ onValueChange = { text ->
+ passwordTextValue.value = text
+ },
+ label = {
+ Text("Password")
+ },
+ placeholder = {
+ Text("Enter your password")
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthPasswordScreen.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthPasswordScreen.kt
new file mode 100644
index 000000000..6cbb01c74
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthPasswordScreen.kt
@@ -0,0 +1,131 @@
+package com.firebase.ui.authcompose.ui.email
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.navigation3.runtime.rememberNavBackStack
+import com.firebase.ui.authcompose.AuthState
+import com.firebase.ui.authcompose.LocalAppModule
+import com.firebase.ui.authcompose.core.AuthUIProvider
+import com.firebase.ui.authcompose.core.Route
+import com.firebase.ui.authcompose.core.Router
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun EmailAuthPasswordScreen(
+ router: Router,
+ email: String,
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val viewModel = LocalAppModule.current.authViewModel
+ val authState by viewModel.state.collectAsState()
+
+ val passwordTextValue = rememberSaveable { mutableStateOf("") }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text("Enter your password")
+ },
+ navigationIcon = {
+ if (router.backStack.size > 1) {
+ IconButton(
+ onClick = {
+ router.pop()
+ }
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ }
+ }
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(horizontal = 16.dp)
+ ) {
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = passwordTextValue.value,
+ onValueChange = { text ->
+ passwordTextValue.value = text
+ },
+ label = {
+ Text("Password")
+ },
+ placeholder = {
+ Text("Enter your password")
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(
+ onClick = {
+ coroutineScope.launch {
+ viewModel.signInWithEmailAndPassword(
+ email = email,
+ password = passwordTextValue.value,
+ )
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.End),
+ enabled = authState !is AuthState.Loading
+ ) {
+ if (authState is AuthState.Loading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp)
+ )
+ } else {
+ Text("Sign In")
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewEmailAuthPasswordScreen() {
+ val provider = AuthUIProvider.Email(
+ requireDisplayName = false,
+ allowNewAccounts = false,
+ enableEmailLinkSignIn = false
+ )
+ val backStack = rememberNavBackStack(Route.EmailAuth.Password(email = "test@gmail.com"))
+ EmailAuthPasswordScreen(
+ router = Router(backStack = backStack),
+ email = "test@gmail.com"
+ )
+}
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthScreen.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthScreen.kt
new file mode 100644
index 000000000..8c06bf722
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthScreen.kt
@@ -0,0 +1,133 @@
+package com.firebase.ui.authcompose.ui.email
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.navigation3.runtime.rememberNavBackStack
+import com.firebase.ui.authcompose.core.AuthUIProvider
+import com.firebase.ui.authcompose.core.Route
+import com.firebase.ui.authcompose.core.Router
+import com.firebase.ui.authcompose.ui.shared.TermsAndPolicyFooter
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun EmailAuthScreen(
+ router: Router,
+ provider: AuthUIProvider.Email,
+) {
+ val emailTextValue = rememberSaveable { mutableStateOf("") }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text("Sign in with your email")
+ },
+ navigationIcon = {
+ if (router.backStack.size > 1) {
+ IconButton(
+ onClick = {
+ router.pop()
+ }
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ }
+ }
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ //.fillMaxSize()
+ .padding(innerPadding)
+ .padding(horizontal = 16.dp)
+ ) {
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = emailTextValue.value,
+ onValueChange = { text ->
+ emailTextValue.value = text
+ },
+ label = {
+ Text("Email")
+ },
+ placeholder = {
+ Text("Enter your email e.g. example@gmail.com")
+ },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ // Signup is hidden for email link sign in
+ if (!provider.enableEmailLinkSignIn) {
+ Button(
+ onClick = {
+ router.push(Route.EmailAuth.Signup(email = emailTextValue.value))
+ }
+ ) {
+ Text("Sign Up")
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ }
+ Button(
+ onClick = {
+ // TODO(demolaf): Check if the email is registered in the backend
+ // and inject the User details into EmailAuth.Password route,
+ // if not found and setAllowNewAccounts == true then go to
+ // EmailAuth.Signup route
+ router.push(Route.EmailAuth.Password(email = emailTextValue.value))
+ }
+ ) {
+ Text("Sign In")
+ }
+ }
+ Spacer(modifier = Modifier.height(32.dp))
+ TermsAndPolicyFooter()
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewEmailAuthScreen() {
+ val provider = AuthUIProvider.Email(
+ requireDisplayName = false,
+ allowNewAccounts = false,
+ enableEmailLinkSignIn = false
+ )
+ val backStack = rememberNavBackStack(Route.EmailAuth(provider))
+ EmailAuthScreen(
+ router = Router(backStack = backStack),
+ provider = provider,
+ )
+}
\ No newline at end of file
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthSignupScreen.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthSignupScreen.kt
new file mode 100644
index 000000000..0bc545a62
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/email/EmailAuthSignupScreen.kt
@@ -0,0 +1,163 @@
+package com.firebase.ui.authcompose.ui.email
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.navigation3.runtime.rememberNavBackStack
+import com.firebase.ui.authcompose.AuthState
+import com.firebase.ui.authcompose.LocalAppModule
+import com.firebase.ui.authcompose.core.AuthUIProvider
+import com.firebase.ui.authcompose.core.Route
+import com.firebase.ui.authcompose.core.Router
+import com.firebase.ui.authcompose.ui.shared.TermsAndPolicyFooter
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun EmailAuthSignupScreen(
+ router: Router,
+ email: String,
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val viewModel = LocalAppModule.current.authViewModel
+ val authState by viewModel.state.collectAsState()
+
+ val emailTextValue = rememberSaveable { mutableStateOf(email) }
+ val passwordTextValue = rememberSaveable { mutableStateOf("") }
+ val nameTextValue = rememberSaveable { mutableStateOf("") }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text("Sign up with your email")
+ },
+ navigationIcon = {
+ if (router.backStack.size > 1) {
+ IconButton(
+ onClick = { router.pop() }
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ }
+ }
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(horizontal = 16.dp)
+ ) {
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = emailTextValue.value,
+ onValueChange = { text ->
+ emailTextValue.value = text
+ },
+ label = {
+ Text("Email")
+ },
+ placeholder = {
+ Text("Enter your email")
+ }
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = passwordTextValue.value,
+ onValueChange = { text ->
+ passwordTextValue.value = text
+ },
+ label = {
+ Text("Password")
+ },
+ placeholder = {
+ Text("Enter your password")
+ }
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = nameTextValue.value,
+ onValueChange = { text ->
+ nameTextValue.value = text
+ },
+ label = {
+ Text("Name")
+ },
+ placeholder = {
+ Text("Enter your name")
+ }
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ Button(
+ modifier = Modifier.align(Alignment.End),
+ onClick = {
+ coroutineScope.launch {
+ viewModel.createOrLinkUserWithEmailAndPassword(
+ name = nameTextValue.value,
+ email = emailTextValue.value,
+ password = passwordTextValue.value,
+ )
+ }
+ },
+ enabled = authState !is AuthState.Loading
+ ) {
+ if (authState is AuthState.Loading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp)
+ )
+ } else {
+ Text("SAVE")
+ }
+ }
+ Spacer(modifier = Modifier.height(32.dp))
+ TermsAndPolicyFooter()
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewEmailAuthSignupScreen() {
+ val provider = AuthUIProvider.Email(
+ requireDisplayName = false,
+ allowNewAccounts = false,
+ enableEmailLinkSignIn = false
+ )
+ val backStack = rememberNavBackStack(Route.EmailAuth.Signup(email = "test@gmail.com"))
+ EmailAuthSignupScreen(
+ router = Router(backStack = backStack),
+ email = "test@gmail.com",
+ )
+}
diff --git a/authCompose/src/main/java/com/firebase/ui/authcompose/ui/shared/TermsAndPolicyFooter.kt b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/shared/TermsAndPolicyFooter.kt
new file mode 100644
index 000000000..d85745d1c
--- /dev/null
+++ b/authCompose/src/main/java/com/firebase/ui/authcompose/ui/shared/TermsAndPolicyFooter.kt
@@ -0,0 +1,24 @@
+package com.firebase.ui.authcompose.ui.shared
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun TermsAndPolicyFooter() {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ Text("Terms of Service")
+ Spacer(modifier = Modifier.width(16.dp))
+ Text("Privacy Policy")
+ }
+}
\ No newline at end of file
diff --git a/authCompose/src/main/res/drawable/fui_ic_googleg_color_24dp.xml b/authCompose/src/main/res/drawable/fui_ic_googleg_color_24dp.xml
new file mode 100644
index 000000000..a818c5945
--- /dev/null
+++ b/authCompose/src/main/res/drawable/fui_ic_googleg_color_24dp.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/authCompose/src/main/res/drawable/fui_ic_mail_white_24dp.xml b/authCompose/src/main/res/drawable/fui_ic_mail_white_24dp.xml
new file mode 100644
index 000000000..66832c1ec
--- /dev/null
+++ b/authCompose/src/main/res/drawable/fui_ic_mail_white_24dp.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/authCompose/src/main/res/values/strings.xml b/authCompose/src/main/res/values/strings.xml
new file mode 100644
index 000000000..dc600327d
--- /dev/null
+++ b/authCompose/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Sign in with Google
+
\ No newline at end of file
diff --git a/authCompose/src/test/java/com/firebase/ui/authcompose/FirebaseAuthScreenTest.kt b/authCompose/src/test/java/com/firebase/ui/authcompose/FirebaseAuthScreenTest.kt
new file mode 100644
index 000000000..fa2e5a65b
--- /dev/null
+++ b/authCompose/src/test/java/com/firebase/ui/authcompose/FirebaseAuthScreenTest.kt
@@ -0,0 +1,217 @@
+package com.firebase.ui.authcompose
+
+import android.content.Context
+import android.os.Bundle
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.credentials.CredentialManager
+import androidx.credentials.CustomCredential
+import androidx.credentials.GetCredentialRequest
+import androidx.credentials.GetCredentialResponse
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.authcompose.core.AuthUIProvider
+import com.firebase.ui.authcompose.core.Route
+import com.firebase.ui.authcompose.core.Router
+import com.firebase.ui.authcompose.di.AppModule
+import com.firebase.ui.authcompose.ui.email.EmailAuthExistingUserScreen
+import com.firebase.ui.authcompose.ui.email.EmailAuthPasswordScreen
+import com.firebase.ui.authcompose.ui.email.EmailAuthScreen
+import com.firebase.ui.authcompose.ui.email.EmailAuthSignupScreen
+import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
+import com.google.common.truth.Truth.assertThat
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import com.google.firebase.auth.FirebaseAuth
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowLog
+
+@Config(sdk = [36])
+@RunWith(RobolectricTestRunner::class)
+class FirebaseAuthScreenTest {
+ private lateinit var firebaseAuth: FirebaseAuth
+ private lateinit var authViewModel: AuthViewModel
+
+ private lateinit var appModule: AppModule
+
+ @Mock
+ private lateinit var mockCredentialManager: CredentialManager
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Before
+ @Throws(Exception::class)
+ fun setUp() {
+ // Provides formatted text representation of the UI tree in the console output
+ ShadowLog.stream = System.out
+
+ MockitoAnnotations.openMocks(this)
+
+ val options = FirebaseOptions.Builder()
+ .setProjectId("demo-test-project") // Use demo project for emulator
+ .setApplicationId("1:123456789:android:abcdef")
+ .setApiKey("fake-api-key")
+ .build()
+
+ val context = ApplicationProvider.getApplicationContext()
+ if (FirebaseApp.getApps(context).isEmpty()) {
+ FirebaseApp.initializeApp(context, options)
+ }
+
+ firebaseAuth = FirebaseAuth.getInstance()
+ firebaseAuth.useEmulator("127.0.0.1", 9099)
+
+ authViewModel =
+ AuthViewModel(
+ credentialManager = mockCredentialManager,
+ firebaseAuth = firebaseAuth
+ )
+
+ appModule = AppModule(authViewModel = authViewModel)
+
+ composeTestRule.setContent {
+
+ val backStack = rememberNavBackStack(Route.Initial)
+ val router = Router(backStack)
+
+ val appModule: AppModule = remember { appModule }
+
+ CompositionLocalProvider(LocalAppModule provides appModule) {
+ NavDisplay(
+ backStack = router.backStack,
+ transitionSpec = {
+ // Slide in from right when navigating forward
+ slideInHorizontally(initialOffsetX = { it }) togetherWith
+ slideOutHorizontally(targetOffsetX = { -it })
+ },
+ popTransitionSpec = {
+ // Slide in from left when navigating back
+ slideInHorizontally(initialOffsetX = { -it }) togetherWith
+ slideOutHorizontally(targetOffsetX = { it })
+ },
+ onBack = {
+ if (router.backStack.size > 1) {
+ router.pop()
+ }
+ },
+ entryProvider = { entry ->
+ val route = entry as Route
+
+ when (route) {
+ is Route.Initial -> NavEntry(route) {
+ AuthMethodPicker(
+ router = router,
+ providers = listOf(
+ AuthUIProvider.Google(clientId = "test-client-id")
+ ),
+ canPop = false,
+ onDismiss = { },
+ )
+ }
+
+ is Route.EmailAuth -> NavEntry(route) {
+ EmailAuthScreen(
+ router,
+ provider = route.provider
+ )
+ }
+
+ is Route.EmailAuth.Password -> NavEntry(route) {
+ EmailAuthPasswordScreen(
+ router,
+ email = route.email
+ )
+ }
+
+ is Route.EmailAuth.Signup -> NavEntry(route) {
+ EmailAuthSignupScreen(
+ router,
+ email = route.email,
+ )
+ }
+
+ is Route.EmailAuth.ExistingUser -> NavEntry(route) {
+ EmailAuthExistingUserScreen(existingUserEmail = route.existingUserEmail)
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+
+ @After
+ fun tearDown() {
+ firebaseAuth.signOut()
+ }
+
+ @Test
+ fun initialNavigationStateIsRouteInitial() {
+ val context = ApplicationProvider.getApplicationContext()
+ composeTestRule
+ .onNodeWithText(context.resources.getString(R.string.fui_sign_in_with_google))
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun whenSignInWithGoogleTappedReturnsSuccess() = runTest {
+ val context = ApplicationProvider.getApplicationContext()
+ val testEmail = "test@example.com"
+ val mockIdToken =
+ "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsInN1YiI6InRlc3QtdXNlci1pZCIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiVGVzdCBVc2VyIiwicGljdHVyZSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYXZhdGFyLmpwZyIsImdpdmVuX25hbWUiOiJUZXN0IiwiZmFtaWx5X25hbWUiOiJVc2VyIiwiaWF0IjoxNjg5NjAwMDAwLCJleHAiOjE2ODk2MDM2MDB9.mock-signature"
+ val mockCredential = mock {
+ on { type } doReturn GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
+ on { data } doReturn Bundle().apply {
+ putString(
+ "com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID_TOKEN",
+ mockIdToken
+ )
+ putString(
+ "com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID",
+ testEmail
+ )
+ }
+ }
+ val mockResult = mock {
+ on { credential } doReturn mockCredential
+ }
+ whenever(mockCredentialManager.getCredential(any(), any()))
+ .thenReturn(mockResult)
+
+ composeTestRule
+ .onNodeWithContentDescription(context.resources.getString(R.string.fui_sign_in_with_google))
+ .performClick()
+
+ // TODO(demolaf): when using viewModelScope.launch "waitUntil" does not work, so I use
+ // rememberCoroutineScope from composables and it works, why?
+ composeTestRule.waitUntil { authViewModel.state.value is AuthState.Success }
+
+ assertThat(authViewModel.state.value)
+ .isEqualTo(AuthState.Success(firebaseAuth.currentUser!!))
+ assertThat(firebaseAuth.currentUser!!.email).isEqualTo(testEmail)
+ }
+}
\ No newline at end of file
diff --git a/authCompose/src/test/resources/robolectric.properties b/authCompose/src/test/resources/robolectric.properties
new file mode 100644
index 000000000..bfe4008e1
--- /dev/null
+++ b/authCompose/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=36
\ No newline at end of file
diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt
index e4aed3d45..f3853d940 100644
--- a/buildSrc/src/main/kotlin/Config.kt
+++ b/buildSrc/src/main/kotlin/Config.kt
@@ -11,7 +11,7 @@ object Config {
}
object Plugins {
- const val android = "com.android.tools.build:gradle:8.8.0"
+ const val android = "com.android.tools.build:gradle:8.9.1"
const val kotlin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
const val google = "com.google.gms:google-services:4.3.8"
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
index cacbe1eed..5f36e8bed 100644
--- a/common/build.gradle.kts
+++ b/common/build.gradle.kts
@@ -9,7 +9,7 @@ android {
defaultConfig {
minSdk = Config.SdkVersions.min
- targetSdk = Config.SdkVersions.target
+ testOptions.targetSdk = Config.SdkVersions.target
resourcePrefix("fui_")
vectorDrawables.useSupportLibrary = true
diff --git a/gradle.properties b/gradle.properties
index 7255d6da2..42ed3691d 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,7 +6,7 @@ android.useAndroidX=true
android.enableJetifier=true
# Temporary added to ignore list as suggested in https://issuetracker.google.com/issues/346686142
# TODO: remove this once we disable Jetifier completely
-android.jetifier.ignorelist=bcprov-jdk18on-1.78.1.jar
+android.jetifier.ignorelist=bcprov-jdk18on-1.78.1.jar, byte-buddy
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 000000000..004e9d201
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,68 @@
+[versions]
+# Compose
+activityCompose = "1.10.1"
+composeBom = "2025.08.01"
+coreKtx = "1.17.0"
+lifecycleRuntimeKtx = "2.9.3"
+
+# Navigation
+nav3Core = "1.0.0-alpha01"
+lifecycleViewmodelNav3 = "1.0.0-alpha01"
+kotlinSerialization = "2.1.21"
+kotlinxSerializationCore = "1.8.1"
+material3AdaptiveNav3 = "1.0.0-alpha01"
+
+# Authentication
+credentials = "1.5.0"
+googleid = "1.1.1"
+
+# Testing
+robolectric = "4.16"
+espressoCore = "3.7.0"
+junit = "4.13.2"
+junitVersion = "1.3.0"
+testCore = "1.7.0"
+mockito = "5.19.0"
+mockitoInline = "5.2.0"
+mockitoKotlin = "6.0.0"
+truth = "1.4.2"
+
+[libraries]
+# Compose
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
+androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-ui = { module = "androidx.compose.ui:ui" }
+androidx-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
+androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
+androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
+
+# Navigation
+androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
+androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
+androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
+kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
+androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }
+
+# Authentication
+androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" }
+androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentials" }
+googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" }
+
+# Testing
+junit = { module = "junit:junit", version.ref = "junit" }
+androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
+androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
+androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" }
+androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" }
+androidx-test-core = { module = "androidx.test:core", version.ref = "testCore" }
+mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" }
+mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInline" }
+mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
+robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
+truth = { module = "com.google.truth:truth", version.ref = "truth" }
+
+[plugins]
+jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"}
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 5c40527d4..aa02b02fc 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/internal/lintchecks/build.gradle.kts b/internal/lintchecks/build.gradle.kts
index 0af73c938..ed108951d 100644
--- a/internal/lintchecks/build.gradle.kts
+++ b/internal/lintchecks/build.gradle.kts
@@ -8,7 +8,6 @@ android {
defaultConfig {
minSdk = Config.SdkVersions.min
- targetSdk = Config.SdkVersions.target
resourcePrefix("fui_")
vectorDrawables.useSupportLibrary = true
@@ -22,6 +21,8 @@ android {
}
lint {
+ targetSdk = Config.SdkVersions.target
+
// Common lint options across all modules
disable += mutableSetOf(
"IconExpectedSize",
@@ -36,6 +37,10 @@ android {
baseline = file("$rootDir/library/quality/lint-baseline.xml")
}
+
+ testOptions {
+ targetSdk = Config.SdkVersions.target
+ }
}
dependencies {
diff --git a/settings.gradle b/settings.gradle
index dd063c8b0..b82d5ccd3 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -22,10 +22,11 @@ gradleEnterprise {
rootProject.buildFileName = 'build.gradle.kts'
include(
- ":app",
-
+ ":app",
+ ":appcompose",
":library",
":auth",
+ ":authCompose",
":common",
":database",
":firestore",