π¨ Modern Realtime 1v1 Chat: Jetpack Compose + Firebase + Room. Offline-first architecture with Material 3. ππ¨
- Minimum SDK: 30 (Android 11)
- Language: Kotlin
- Utilizes Coroutines & Flow for reactive and asynchronous programming
- Jetpack Compose β Modern toolkit for building native UI
- Navigation (v3) β Type-safe and flexible screen navigation
- Hilt β Dependency Injection (DI)
- Room Database β Local persistence for offline support
- Paging 3 β Efficient large dataset loading
- ViewModel β Lifecycle-aware state management
- WorkManager β Background task scheduling
- DataStore β Modern key-value storage for preferences
-
Firebase Ecosystem
- Authentication β Email & Google Sign-In
- Cloud Firestore β Real-time NoSQL database
- Cloud Messaging (FCM) β Push notifications
-
Backend:
- Node.js with (TypeScript) deployed on Vercel: acts as middleware for FCM and custom business logic
Tip
You can checkout Backend For Replee here.
- Cloudinary: Media upload, storage, and transformation
- Retrofit & Gson β HTTP client & JSON parsing
- Coil β Image loading and caching
- Zoomable β Pinch-to-zoom support for images
- MaterialKolor β Dynamic Material 3 color generation
- Timber β Logging utility
- unDraw β Customizable illustrations
- Flow Operator β Simplifies complex Flow transformations
- Truth β Readable assertions
- MockK β Kotlin-first mocking library
- Coroutines Test β Testing suspend functions
- Turbine β Testing Kotlin Flow emissions
Replee is built upon the MVI (Model-View-Intent) architectural pattern, integrated with Clean Architecture principles. This ensures a predictable state management system, high testability, and a clear separation of concerns.
Unlike traditional MVVM, Replee leverages MVI to handle complex UI states in a chat environment. The data flows in a single direction, making the app easier to debug and scale.
-
Model (State): A single, immutable source of truth for the UI state. Any change in the data results in a new State being emitted to the View.
-
View: Jetpack Compose functions that observe the State and render the UI. The View doesn't hold logic; it only displays what the State dictates.
-
Intent (Actions): Represents the user's intention (e.g., SendMessage, LoadChatHistory). These intents are dispatched to the ViewModel to trigger business logic.
Replee is built with a multi-module architecture to ensure a highly scalable and maintainable codebase. By separating features and core logic into independent modules, we achieve faster build times, better separation of concerns, and improved reusability across the project.
project-module:
- app: App initialization, Dependency Injection (Hilt) configuration, global navigation hosting, notifications, services,etc
- core/
- domain: Contains Business Logic, Entities, and Repository Interfaces. No Android dependencies
- data: The implementation of repositories. Orchestrates data from Network and Database.
- network: Infrastructure for API calls (Retrofit, Firebase, Cloudinary).
- database: Local persistence using Room Database.
- design_system: Shared UI components, Theme, and Design Tokens (Material 3).
- common: Shared utilities, extensions, and base classes used by everyone.
- test: Shared testing frameworks and test doubles (MockK , Turbine).
- feature_autht: Supports Multi-method authentication including Email/Password and Google One Tap Sign-In.
- feature_chat: : Real-time messaging with Instant Delivery Status, image sharing (integrated with Cloudinary).
- feature_profile: Custom profile customization including Avatar uploads.
Note: All feature contain UI logic (Compose), ViewModels, and MVI State management.
They are isolated from each other. They only communicate through the :app module or shared :core interfaces.
Replee is designed to be fully functional in low-connectivity environments, ensuring a seamless user experience regardless of network status.
We implement the Single Source of Truth pattern using Room as the local cache and Firestore as the remote source.
-
Local-Persistence: All data is first persisted in the local Room database before being displayed.
-
Background Sync: Using WorkManager and Coroutines, the app synchronizes local changes with the backend once the connection is restored.
-
Real-time Listening: We use Firestore Snapshots to listen for remote changes and immediately update the local cache.
Code sample
sealed class DataChange<out T> {
data object Empty : DataChange<Nothing>()
data class Upsert<out T>(val data: T) : DataChange<T>()
data class Delete(val id: String) : DataChange<Nothing>()
}
//Listen Data From Network
class ListenMessageChangeUseCase @Inject constructor(
private val messageRepository: MessageRepository
) {
operator fun invoke(conversationId: String): Flow<List<DataChange<Message>>> {
return messageRepository.observeNetworkMessageChange(conversationId)
}
}
//Update data changes from Network to Local
class UpdateMessageChangeUseCase @Inject constructor(
private val messageRepository: MessageRepository
) {
suspend operator fun invoke(
dataChanges: List<DataChange<Message>>
) {
val upserts: MutableList<Message> = mutableListOf()
val deletes: MutableList<String> = mutableListOf()
for (change in dataChanges) {
when (change) {
is DataChange.Delete -> deletes.add(change.id)
is DataChange.Upsert -> upserts.add(change.data)
DataChange.Empty -> {
//TODO
}
}
}
Timber.d("Upserts: $upserts")
Timber.d("Deletes: $deletes")
messageRepository.updateLocalDataChange(
upsert = upserts,
delete = deletes
)
}
}
//Execute in ViewModel
private fun listenToMessageChange() {
viewModelScope.launch {
listenMessageChangeUseCase(conversationId = conversationId)
.collect { dataChanges ->
updateMessageChangeUseCase(dataChanges)
}
}
}
Our strategy focuses on:
-
Graceful Degradation: Showing cached data when the network fails.
-
Retry Policies: Exponential backoff for failed network requests.
-
Visual Feedback: Clear indicators for "Pending", "Synced", or "Failed" states.
Code sample
class ReadMessageUseCase @Inject constructor(
private val syncManager: SyncManager,
private val conversationRepository: ConversationRepository,
private val messageRepository: MessageRepository,
private val workerScheduler: WorkerScheduler
) {
suspend operator fun invoke(
conversationId: String,
receiverId: String,
): NetworkResult<String> {
return conversationRepository.markAllMessagesRead(
conversationId = conversationId,
currentUserId = receiverId
).then { conversationId ->
messageRepository.markAllMessagesRead(
conversationId = conversationId,
receiverId = receiverId
)
}
//Mark unsynced in Local Database
.onFailure {
syncManager.updateConversationStatus(
conversationId = conversationId,
synced = false
)
//Schedule a synchronizing worker
workerScheduler.scheduleConversationSyncWorker()
}
//Mark synced in LocalDatabase
.onSuccess {
syncManager.updateConversationStatus(conversationId = conversationId, synced = true)
}
}
}




