Building a Real-Time Chat App in Android with Kotlin and Firebase: A Family-Friendly Guide Using Clean Architecture

Hello, devs Today, we’re creating a real-time chat application using Firebase in Kotlin and organizing our code with Clean Architecture. This guide will walk you through every step, from setting up Firebase to structuring your app with Clean Architecture principles, ensuring that your chat app is clean, maintainable, and scalable. So, let’s get started and build something fantastic together!

What is Firebase Real-Time Database?

Firebase Realtime Database is a cloud-hosted NoSQL database that enables you to store and sync data between users in real-time. It’s perfect for applications that require instant data updates, such as chat apps, where messages need to be delivered and displayed immediately. With Firebase, you don’t have to worry about server-side infrastructure or complex data synchronization logic—Firebase handles it all for you.

Why Use Clean Architecture?

Clean Architecture is a software design philosophy that helps you structure your code in a way that separates concerns, making it easier to manage, test, and scale. By following Clean Architecture, you ensure that your application’s core logic remains independent of frameworks, making your codebase more flexible and adaptable.

Project Setup

Step 1: Set Up Firebase in Your Android Project

  1. Create a Firebase Project: First Go to the Firebase Console, create a new project, and follow the setup instructions.

  2. Add Firebase to Your Android Project:

    • Download the google-services.json file from Firebase and place it in the app/ directory of your Android project.

    • Add the Firebase dependencies to your build.gradle files.

    // Project-level build.gradle
    buildscript {
        dependencies {
            classpath 'com.google.gms:google-services:4.3.15'
        }
    }

    // App-level build.gradle
    apply plugin: 'com.google.gms.google-services'

    dependencies {
        implementation 'com.google.firebase:firebase-database-ktx:20.1.0'
        implementation 'com.google.firebase:firebase-auth-ktx:22.0.0'
    }
  1. Set Up Firebase Authentication: To manage users, enable Firebase Authentication in the Firebase console. For a chat app, you might use email/password authentication or other methods provided by Firebase.

Step 2: Structure Your Project with Clean Architecture

We’ll divide our project into three main layers:

  1. Data Layer: Handles data sources and interactions with Firebase.

  2. Domain Layer: Contains business logic and use cases.

  3. Presentation Layer: Manages UI and user interactions.

Implementing Real-Time Chat

1. Data Layer

In the data layer, we’ll create a repository for managing chat messages and user authentication.

  • FirebaseRepository.kt: This interface and its implementation will handle interactions with Firebase Realtime Database and Firebase Authentication.

      interface FirebaseRepository {
          fun getMessages(chatRoomId: String): LiveData<List<Message>>
          fun sendMessage(chatRoomId: String, message: Message)
          fun getCurrentUser(): User?
          fun authenticateUser(email: String, password: String, callback: (Boolean) -> Unit)
      }
    
      class FirebaseRepositoryImpl : FirebaseRepository {
    
          private val database = FirebaseDatabase.getInstance().reference
          private val auth = FirebaseAuth.getInstance()
    
          override fun getMessages(chatRoomId: String): LiveData<List<Message>> {
              val messagesLiveData = MutableLiveData<List<Message>>()
              val messagesRef = database.child("chatRooms").child(chatRoomId).child("messages")
    
              messagesRef.addValueEventListener(object : ValueEventListener {
                  override fun onDataChange(snapshot: DataSnapshot) {
                      val messages = mutableListOf<Message>()
                      for (dataSnapshot in snapshot.children) {
                          val message = dataSnapshot.getValue(Message::class.java)
                          message?.let { messages.add(it) }
                      }
                      messagesLiveData.value = messages
                  }
    
                  override fun onCancelled(error: DatabaseError) {
                      // Handle possible errors
                  }
              })
    
              return messagesLiveData
          }
    
          override fun sendMessage(chatRoomId: String, message: Message) {
              val messagesRef = database.child("chatRooms").child(chatRoomId).child("messages")
              messagesRef.push().setValue(message)
          }
    
          override fun getCurrentUser(): User? {
              return auth.currentUser?.let {
                  User(it.uid, it.email ?: "Unknown")
              }
          }
    
          override fun authenticateUser(email: String, password: String, callback: (Boolean) -> Unit) {
              auth.signInWithEmailAndPassword(email, password).addOnCompleteListener { task ->
                  callback(task.isSuccessful)
              }
          }
      }
    
  • Message.kt: Data class for chat messages.

      data class Message(
          val userId: String = "",
          val userName: String = "",
          val text: String = "",
          val timestamp: Long = System.currentTimeMillis()
      )
    
  • User.kt: Data class for users.

      data class User(
          val id: String,
          val email: String
      )
    

2. Domain Layer

In this layer, we define use cases that encapsulate the core business logic.

  • SendMessageUseCase.kt: Handles the logic for sending a message.

      class SendMessageUseCase(private val repository: FirebaseRepository) {
    
          fun execute(chatRoomId: String, message: Message) {
              repository.sendMessage(chatRoomId, message)
          }
      }
    
  • GetMessagesUseCase.kt: Retrieves messages for a chat room.

      class GetMessagesUseCase(private val repository: FirebaseRepository) {
    
          fun execute(chatRoomId: String): LiveData<List<Message>> {
              return repository.getMessages(chatRoomId)
          }
      }
    
  • AuthenticateUserUseCase.kt: Manages user authentication.

      class AuthenticateUserUseCase(private val repository: FirebaseRepository) {
    
          fun execute(email: String, password: String, callback: (Boolean) -> Unit) {
              repository.authenticateUser(email, password, callback)
          }
      }
    

3. Presentation Layer

In the presentation layer, we manage the UI and user interactions.

  • ChatViewModel.kt: ViewModel that interacts with use cases to handle chat functionality.

      class ChatViewModel(
          private val getMessagesUseCase: GetMessagesUseCase,
          private val sendMessageUseCase: SendMessageUseCase
      ) : ViewModel() {
    
          private val _messages = MutableLiveData<List<Message>>()
          val messages: LiveData<List<Message>> get() = _messages
    
          fun loadMessages(chatRoomId: String) {
              getMessagesUseCase.execute(chatRoomId).observeForever {
                  _messages.value = it
              }
          }
    
          fun sendMessage(chatRoomId: String, message: Message) {
              sendMessageUseCase.execute(chatRoomId, message)
          }
      }
    
  • AuthViewModel.kt: ViewModel for handling user authentication.

      class AuthViewModel(private val authenticateUserUseCase: AuthenticateUserUseCase) : ViewModel() {
    
          private val _authStatus = MutableLiveData<Boolean>()
          val authStatus: LiveData<Boolean> get() = _authStatus
    
          fun authenticate(email: String, password: String) {
              authenticateUserUseCase.execute(email, password) { isSuccess ->
                  _authStatus.value = isSuccess
              }
          }
      }
    
  • ChatActivity.kt: Activity that displays chat messages and handles user interactions.

      class ChatActivity : AppCompatActivity() {
    
          private lateinit var viewModel: ChatViewModel
          private lateinit var adapter: MessageAdapter
    
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContentView(R.layout.activity_chat)
    
              viewModel = ViewModelProvider(this).get(ChatViewModel::class.java)
              adapter = MessageAdapter()
    
              val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
              recyclerView.layoutManager = LinearLayoutManager(this)
              recyclerView.adapter = adapter
    
              val chatRoomId = "sampleChatRoomId" // Get chat room ID dynamically
              viewModel.loadMessages(chatRoomId)
    
              viewModel.messages.observe(this, Observer { messages ->
                  adapter.submitList(messages)
              })
    
              val sendButton = findViewById<Button>(R.id.sendButton)
              val messageEditText = findViewById<EditText>(R.id.messageEditText)
    
              sendButton.setOnClickListener {
                  val text = messageEditText.text.toString()
                  val currentUser = viewModel.getCurrentUser()
                  if (text.isNotEmpty() && currentUser != null) {
                      val message = Message(
                          userId = currentUser.id,
                          userName = currentUser.email,
                          text = text
                      )
                      viewModel.sendMessage(chatRoomId, message)
                      messageEditText.text.clear()
                  }
              }
          }
      }
    
  • MessageAdapter.kt: RecyclerView adapter for displaying messages.

      class MessageAdapter : ListAdapter<Message, MessageAdapter.MessageViewHolder>(MessageDiffCallback()) {
    
          override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
              val view = LayoutInflater.from(parent.context).inflate(R.layout.item_message, parent, false)
              return MessageViewHolder(view)
          }
    
          override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
              val message = getItem(position)
              holder.bind(message)
          }
    
          class MessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
              private val userNameTextView: TextView = itemView.findViewById(R.id.userNameTextView)
              private val messageTextView: TextView = itemView.findViewById(R.id.messageTextView)
    
              fun bind(message: Message) {
                  userNameTextView.text = message.userName
                  messageTextView.text = message.text
              }
          }
    
          class MessageDiffCallback : DiffUtil.ItemCallback<Message>() {
              override fun areItemsTheSame(oldItem: Message, newItem: Message): Boolean {
                  return oldItem.timestamp == newItem.timestamp
              }
    
              override fun areContentsTheSame(oldItem: Message, newItem: Message): Boolean {
                  return oldItem == newItem
              }
          }
      }
    

Dependency Injection with Koin

To manage dependencies, we’ll use Koin.

  • AppModule.kt: Define your Koin module with dependencies.

      val appModule = module {
          single<FirebaseRepository> { FirebaseRepositoryImpl() }
          single { GetMessagesUseCase(get()) }
          single { SendMessageUseCase(get()) }
          single { AuthenticateUserUseCase(get()) }
          viewModel { ChatViewModel(get(), get()) }
          viewModel { AuthViewModel(get()) }
      }
    
  • MyApplication.kt: Start Koin in your application class.

      class MyApplication : Application() {
          override fun onCreate() {
              super.onCreate()
              startKoin {
                  androidContext(this@MyApplication)
                  modules(appModule)
              }
          }
      }
    

Testing Your Chat App

Testing is essential to ensure your app works as expected.

  • FirebaseRepositoryTest.kt: Unit test for the repository.

      class FirebaseRepositoryTest {
    
          private val repository = FirebaseRepositoryImpl()
    
          @Test
          fun `getMessages should return list of messages`() {
              val chatRoomId = "sampleChatRoomId"
              val messages = repository.getMessages(chatRoomId).getOrAwaitValue()
              assert(messages.isNotEmpty())
          }
    
          @Test
          fun `sendMessage should send message successfully`() {
              val chatRoomId = "sampleChatRoomId"
              val message = Message(userId = "user1", userName = "User One", text = "Hello!")
              repository.sendMessage(chatRoomId, message)
              // Verify message is sent
          }
      }
    
  • ChatViewModelTest.kt: Unit test for the ViewModel.

      class ChatViewModelTest {
    
          private val getMessagesUseCase = mockk<GetMessagesUseCase>()
          private val sendMessageUseCase = mockk<SendMessageUseCase>()
          private val viewModel = ChatViewModel(getMessagesUseCase, sendMessageUseCase)
    
          @Test
          fun `loadMessages should update messages LiveData`() {
              val messages = listOf(Message(userId = "user1", userName = "User One", text = "Hello!"))
              every { getMessagesUseCase.execute(any()) } returns MutableLiveData(messages)
              viewModel.loadMessages("sampleChatRoomId")
              assert(viewModel.messages.value == messages)
          }
    
          @Test
          fun `sendMessage should call sendMessageUseCase`() {
              val message = Message(userId = "user1", userName = "User One", text = "Hello!")
              viewModel.sendMessage("sampleChatRoomId", message)
              verify { sendMessageUseCase.execute("sampleChatRoomId", message) }
          }
      }
    

Congratulations! You’ve now built a real-time chat app using Firebase and Kotlin, structured with Clean Architecture. By following this guide, you’ve ensured that your app is not only functional but also well-organized and maintainable.

This project is a great way to practice your Android development skills and see the power of Firebase in action.

Okay devs, I hope you found this guide helpful and enjoyable. If you have any questions or run into any issues, feel free to reach out. I’m always here to help!


Connect with Me:

Hey there! If you enjoyed reading this blog and found it informative, why not connect with me on LinkedIn? 😊 You can also follow my Instagram page for more mobile development-related content. 📲👨‍💻 Let’s stay connected, share knowledge and have some fun in the exciting world of app development! 🌟

Check out my Instagram page

Check out my LinkedIn

0
Subscribe to my newsletter

Read articles from Mayursinh Parmar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mayursinh Parmar
Mayursinh Parmar

📱Mobile App Developer | Android & Flutter 🌟💡 Passionate about creating intuitive and engaging apps 💭✨ Let’s shape the future of mobile technology!