Chat is the classic real-time app: you send a message and everyone in the room sees it a moment later. Building your own server for this takes time, so this project uses Firebase, Google's backend service. Firebase Authentication handles accounts, Cloud Firestore stores messages and pushes changes to every connected phone, and Cloud Messaging can wake a phone with a notification.
This project needs an account and setup that only you can do: a Firebase project and your own google-services.json file. Follow the current setup steps in the Firebase console, because its screens change. The security rules at the end matter as much as the app code, because they decide who can read and write your data.
Concepts in this project
- Firebase setup: create a project, register your application ID, download
google-services.jsoninto theappfolder and add the Google services plugin. - Authentication: email and password sign-up and sign-in, with the result wrapped in
Resultso the ViewModel can show errors. - Firestore documents: each message is a document in
rooms/general/messageswith text, sender id, name and a server timestamp. - callbackFlow: wraps a Firestore snapshot listener as a Flow, and removes the listener when nobody collects it.
- Cloud Messaging: a
FirebaseMessagingServicereceives pushes. Sending them needs a server or a Cloud Function. - Security rules: only signed-in users can read, a message may only be created with the sender's own id, and nobody can edit or delete.
Create the project
- Create a Firebase project in the Firebase console, add an Android app with your application ID and download
google-services.json. - Enable Email/Password in Authentication, and create a Firestore database.
- New Empty Activity project
Chat, packagecom.example.chat. Add the Google services plugin and the Firebase libraries from the Project 13 block. - Create the files below, and register the messaging service in the manifest.
- Paste the rules file into Firestore Rules and publish.
`Gradle dependencies for projects 11 to 21 (use the block for this project)`
// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")
// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.
// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.
// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.
// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// ---- Project 19 AI chatbot
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 21 Job portal
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
Chat/app/
google-services.json from your Firebase console (do not share publicly)
src/main/java/com/example/chat/
ChatRepository.kt auth, send, live messages
ChatViewModel.kt sign-in state and messages
ChatScreen.kt login and chat room
ChatMessagingService.kt push receiver
MainActivity.kt
firestore.rules
The code
`app/src/main/java/com/example/chat/ChatRepository.kt`
package com.example.chat
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.Query
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await
data class Message(
val id: String = "",
val text: String = "",
val from: String = "",
val name: String = "",
)
class ChatRepository(
private val auth: FirebaseAuth = FirebaseAuth.getInstance(),
private val db: FirebaseFirestore = FirebaseFirestore.getInstance(),
) {
val currentUid: String? get() = auth.currentUser?.uid
suspend fun register(email: String, password: String): Result<Unit> = runCatching {
auth.createUserWithEmailAndPassword(email, password).await()
Unit
}
suspend fun signIn(email: String, password: String): Result<Unit> = runCatching {
auth.signInWithEmailAndPassword(email, password).await()
Unit
}
fun signOut() {
auth.signOut()
}
suspend fun send(roomId: String, text: String): Result<Unit> = runCatching {
val user = auth.currentUser ?: error("Not signed in")
val data = mapOf(
"text" to text,
"from" to user.uid,
"name" to (user.email ?: "unknown"),
"at" to FieldValue.serverTimestamp(),
)
db.collection("rooms").document(roomId).collection("messages").add(data).await()
Unit
}
fun messages(roomId: String): Flow<List<Message>> = callbackFlow {
val registration = db.collection("rooms").document(roomId).collection("messages")
.orderBy("at", Query.Direction.ASCENDING)
.addSnapshotListener { snapshot, _ ->
val list = snapshot?.documents?.map { doc ->
Message(
id = doc.id,
text = doc.getString("text").orEmpty(),
from = doc.getString("from").orEmpty(),
name = doc.getString("name").orEmpty(),
)
}.orEmpty()
trySend(list)
}
awaitClose { registration.remove() }
}
}
`app/src/main/java/com/example/chat/ChatViewModel.kt`
package com.example.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class AuthState(
val signedIn: Boolean = false,
val busy: Boolean = false,
val error: String? = null,
)
class ChatViewModel(private val repo: ChatRepository = ChatRepository()) : ViewModel() {
private val _auth = MutableStateFlow(AuthState(signedIn = repo.currentUid != null))
val auth: StateFlow<AuthState> = _auth.asStateFlow()
val myUid: String? get() = repo.currentUid
val messages: StateFlow<List<Message>> = repo.messages("general")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun signIn(email: String, password: String, create: Boolean) {
if (!email.contains("@") || password.length < 6) {
_auth.value = AuthState(error = "Enter a valid email and a password of 6 or more characters")
return
}
_auth.value = AuthState(busy = true)
viewModelScope.launch {
val result = if (create) repo.register(email.trim(), password) else repo.signIn(email.trim(), password)
_auth.value = result.fold(
onSuccess = { AuthState(signedIn = true) },
onFailure = { AuthState(error = it.message ?: "Sign in failed") },
)
}
}
fun send(text: String) {
val clean = text.trim()
if (clean.isEmpty()) return
viewModelScope.launch { repo.send("general", clean) }
}
fun signOut() {
repo.signOut()
_auth.value = AuthState()
}
}
`app/src/main/java/com/example/chat/ChatScreen.kt`
package com.example.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun ChatApp(vm: ChatViewModel) {
val auth by vm.auth.collectAsState()
if (auth.signedIn) ChatRoom(vm) else LoginScreen(vm, auth)
}
@Composable
private fun LoginScreen(vm: ChatViewModel, auth: AuthState) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("Chat", style = MaterialTheme.typography.headlineLarge)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
auth.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { vm.signIn(email, password, create = false) }, enabled = !auth.busy) { Text("Sign in") }
TextButton(onClick = { vm.signIn(email, password, create = true) }, enabled = !auth.busy) { Text("Create account") }
}
}
}
@Composable
private fun ChatRoom(vm: ChatViewModel) {
val messages by vm.messages.collectAsState()
var draft by remember { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
}
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("General", style = MaterialTheme.typography.titleLarge)
TextButton(onClick = vm::signOut) { Text("Sign out") }
}
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(messages, key = { it.id }) { m ->
val mine = m.from == vm.myUid
Box(Modifier.fillMaxWidth(), contentAlignment = if (mine) Alignment.CenterEnd else Alignment.CenterStart) {
Surface(
shape = RoundedCornerShape(12.dp),
color = if (mine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
) {
Column(Modifier.padding(10.dp)) {
if (!mine) Text(m.name, style = MaterialTheme.typography.labelSmall)
Text(m.text)
}
}
}
}
}
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("Message") },
modifier = Modifier.weight(1f),
)
Button(
onClick = {
vm.send(draft)
draft = ""
},
modifier = Modifier.padding(start = 8.dp),
) { Text("Send") }
}
}
}
`app/src/main/java/com/example/chat/ChatMessagingService.kt`
package com.example.chat
import android.app.NotificationChannel
import android.app.NotificationManager
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
// Receives push messages from Firebase Cloud Messaging.
// Sending them needs a server or Cloud Function that calls FCM when a message is created.
class ChatMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
// Send this token to your server so it knows where to push for this user.
}
override fun onMessageReceived(message: RemoteMessage) {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel("chat", "Chat messages", NotificationManager.IMPORTANCE_DEFAULT),
)
val title = message.data["title"] ?: "New message"
val body = message.data["body"] ?: ""
val notification = NotificationCompat.Builder(this, "chat")
.setSmallIcon(android.R.drawable.ic_dialog_email)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.build()
// On Android 13 and newer the user must have allowed POST_NOTIFICATIONS.
manager.notify(System.currentTimeMillis().toInt(), notification)
}
}
`app/src/main/java/com/example/chat/MainActivity.kt`
package com.example.chat
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Surface(Modifier.systemBarsPadding()) {
ChatApp(viewModel<ChatViewModel>())
}
}
}
}
}
`firestore.rules (paste into Firestore > Rules)`
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /rooms/{roomId}/messages/{msgId} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.from == request.auth.uid
&& request.resource.data.text is string
&& request.resource.data.text.size() <= 1000;
allow update, delete: if false;
}
}
}
How it works
- The ViewModel starts signed in if Firebase already has a user. Otherwise the login screen validates the email and password length before calling Firebase.
messagesis a Flow built withcallbackFlow. Every new message triggers the listener, which sends the full list to the screen.- The room screen scrolls to the newest message when the list grows, and aligns your own messages to the right.
- The messaging service builds a notification from the data in a push message. A server must send those pushes.
Expected output
The first screen shows Chat, an email box, a password box, a Sign in button and a Create account button. Create an account with a test email and a password of at least 6 characters. You land in the General room. Type a message and press Send: it appears on the right. Install the app on a second phone or emulator, create another account and send a message: it appears on the first phone within a second or two, on the left with the sender's email above it. Signing out returns to the login screen. A wrong password shows an error message.
Your task
- Set up Firebase, build the app and chat between two devices.
- Add several rooms, and a room list screen.
- Test the rules in the Firebase Rules Playground: try to write a message with another user's id.
- Add a Cloud Function that sends a push when a message is created (see the Firebase documentation).
How to check it worked
- Two devices see each other's messages without a refresh.
- A signed-out user cannot read messages (test in the Rules Playground).
- Your
google-services.jsonis not in a public repository unless you accept that.