Project 13: Real-Time Chat App with Firebase

Android Development with Kotlin: 21 Real Projects · More Real-World Projects · 19 min read · Updated

Live messages

  1. Phone A sends a message to Firestore.
  2. Security rules check that the user is signed in and the sender id is theirs.
  3. Firestore stores it and tells every listener.
  4. Phone B receives the new list through its snapshot listener, so the message appears at once.

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.json into the app folder and add the Google services plugin.
  • Authentication: email and password sign-up and sign-in, with the result wrapped in Result so the ViewModel can show errors.
  • Firestore documents: each message is a document in rooms/general/messages with 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 FirebaseMessagingService receives 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

  1. Create a Firebase project in the Firebase console, add an Android app with your application ID and download google-services.json.
  2. Enable Email/Password in Authentication, and create a Firestore database.
  3. New Empty Activity project Chat, package com.example.chat. Add the Google services plugin and the Firebase libraries from the Project 13 block.
  4. Create the files below, and register the messaging service in the manifest.
  5. 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.
  • messages is a Flow built with callbackFlow. 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

  1. Set up Firebase, build the app and chat between two devices.
  2. Add several rooms, and a room list screen.
  3. Test the rules in the Firebase Rules Playground: try to write a message with another user's id.
  4. 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.json is not in a public repository unless you accept that.

Frequently asked questions

Why use callbackFlow?

Firestore gives a callback when data changes. callbackFlow turns that into a Flow and lets you clean up the listener with awaitClose.

Is it safe to publish google-services.json?

It only identifies your project, so security rules and App Check must protect the data. Many teams still keep it private.

How do push notifications get sent?

A server or a Cloud Function calls the Firebase Cloud Messaging API with the receiver's device token. The app only receives them.