How to Implement the SOLID Principles in an Android App Using Kotlin

The SOLID principles are fundamental design principles that help developers create maintainable, scalable, and robust software. These principles, initially introduced by Robert C. Martin (Uncle Bob), provide a framework for writing clean and reusable code. In this article, we'll explore how to implement the SOLID principles in an Android app using Kotlin, with practical examples and use cases for each principle.



1. Single Responsibility Principle (SRP)

Definition: A class should have only one reason to change.

Use Case: Managing responsibilities for a User Profile feature.
A common violation of SRP in Android development is creating a class that handles UI logic, network calls, and database operations. By adhering to SRP, we can separate these responsibilities.

Implementation Example:

class UserProfileRepository {
    fun getUserProfile(userId: String): UserProfile {
        // Fetch user profile from API or database
    }
}

class UserProfileViewModel(private val repository: UserProfileRepository) : ViewModel() {
    val userProfile = MutableLiveData<UserProfile>()

    fun loadUserProfile(userId: String) {
        viewModelScope.launch {
            val profile = repository.getUserProfile(userId)
            userProfile.value = profile
        }
    }
}

Here, the UserProfileRepository handles data fetching, while the UserProfileViewModel handles UI logic.


2. Open/Closed Principle (OCP)

Definition: A class should be open for extension but closed for modification.

Use Case: Adding new payment methods without modifying existing code.
When adding new functionality, we should avoid changing existing classes. Instead, we can extend them.

Implementation Example:

interface PaymentProcessor {
    fun processPayment(amount: Double)
}

class CreditCardPayment : PaymentProcessor {
    override fun processPayment(amount: Double) {
        println("Processing credit card payment of $$amount")
    }
}

class PayPalPayment : PaymentProcessor {
    override fun processPayment(amount: Double) {
        println("Processing PayPal payment of $$amount")
    }
}

fun processPayment(paymentProcessor: PaymentProcessor, amount: Double) {
    paymentProcessor.processPayment(amount)
}

Here, adding a new payment method requires creating a new class that implements PaymentProcessor, without modifying existing code.


3. Liskov Substitution Principle (LSP)

Definition: Subtypes must be substitutable for their base types.

Use Case: Implementing different types of notifications (e.g., Email and SMS).
The derived class should work seamlessly in place of its base class without altering the program's behavior.

Implementation Example:

open class Notification {
    open fun send(message: String) {
        println("Sending notification: $message")
    }
}

class EmailNotification : Notification() {
    override fun send(message: String) {
        println("Sending email: $message")
    }
}

class SMSNotification : Notification() {
    override fun send(message: String) {
        println("Sending SMS: $message")
    }
}

fun notifyUser(notification: Notification, message: String) {
    notification.send(message)
}

4. Interface Segregation Principle (ISP)

Definition: A class should not be forced to implement methods it does not use.

Use Case: Managing different authentication methods like Google and Facebook login.
Instead of creating a single interface with all authentication methods, split it into smaller interfaces.

Implementation Example:

interface GoogleAuth {
    fun authenticateWithGoogle()
}

interface FacebookAuth {
    fun authenticateWithFacebook()
}

class GoogleLogin : GoogleAuth {
    override fun authenticateWithGoogle() {
        println("Authenticated with Google")
    }
}

class FacebookLogin : FacebookAuth {
    override fun authenticateWithFacebook() {
        println("Authenticated with Facebook")
    }
}

5. Dependency Inversion Principle (DIP)

Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.

Use Case: Injecting dependencies in a weather app.
We can use interfaces and dependency injection to reduce coupling between classes.

Implementation Example:

interface WeatherService {
    fun fetchWeatherData(city: String): WeatherData
}

class OpenWeatherService : WeatherService {
    override fun fetchWeatherData(city: String): WeatherData {
        // Fetch weather data from OpenWeather API
    }
}

class WeatherViewModel(private val service: WeatherService) : ViewModel() {
    val weatherData = MutableLiveData&lt;WeatherData&gt;()

    fun getWeather(city: String) {
        viewModelScope.launch {
            val data = service.fetchWeatherData(city)
            weatherData.value = data
        }
    }
}

// Using Hilt for Dependency Injection
@Module
@InstallIn(SingletonComponent::class)
object WeatherModule {
    @Provides
    fun provideWeatherService(): WeatherService = OpenWeatherService()
}

Additional Detailed Example: A Ride-Sharing App

Scenario: A ride-sharing app implementing SOLID principles to manage different ride types (e.g., economy, luxury) and location services.

Single Responsibility Principle

Separate ride management from location tracking:

class RideManager {
    fun calculateFare(distance: Double, rate: Double): Double {
        return distance * rate
    }
}

class LocationTracker {
    fun getCurrentLocation(): Location {
        // Logic to fetch current location
    }
}

Open/Closed Principle

Add new ride types without modifying the existing system:

abstract class Ride {
    abstract fun calculateFare(distance: Double): Double
}

class EconomyRide : Ride() {
    override fun calculateFare(distance: Double): Double {
        return distance * 0.5
    }
}

class LuxuryRide : Ride() {
    override fun calculateFare(distance: Double): Double {
        return distance * 1.5
    }
}

Liskov Substitution Principle

Substitute ride types without affecting functionality:

fun showRideFare(ride: Ride, distance: Double) {
    println("Fare: ${ride.calculateFare(distance)}")
}

Interface Segregation Principle

Segregate ride payment and ride tracking:

interface RidePayment {
    fun processPayment(amount: Double)
}

interface RideTracking {
    fun trackRide(rideId: String)
}

Dependency Inversion Principle

Decouple the ViewModel from the ride repository:

interface RidePayment {
    fun processPayment(amount: Double)
}

interface RideTracking {
    fun trackRide(rideId: String)
}

Summary: Why SOLID Principles Matter in Android Development

  • Maintainability: Classes with a single responsibility and clear boundaries are easier to debug and extend.
  • Scalability: By adhering to OCP and DIP, the app can grow without disrupting existing functionality.
  • Reusability: Following ISP and LSP ensures that components are modular and can be reused across different parts of the application.
  • Testability: SOLID principles promote decoupled and well-structured code, making it easier to write and maintain unit tests.

By applying the SOLID principles, you can design Android apps with improved modularity, testability, and extensibility. Whether building an e-commerce app, a music player, or a ride-sharing app, these principles help organize code effectively. They ensure that your app remains adaptable to future requirements while reducing the risk of bugs and technical debt. Use these examples as a starting point for implementing SOLID in your projects!

Code Challenge: Implement a Task Manager in Kotlin Android

An overview of common LeetCode-style Android engineer coding challenges in Kotlin, incorporating the latest UI and technologies in Android development, such as Jetpack Compose, Kotlin Coroutines, and Flow.

These challenges simulate real-world scenarios and test problem-solving skills, algorithmic thinking, and practical Android development knowledge.




1. Challenge: Implement a Task Manager

Problem Statement
Create an Android app using Jetpack Compose that allows users to manage tasks. Users can:

Features Implemented:

    1. Add tasks with title and description.
    2. Toggle task completion status.
    3. Delete tasks.
    4. Filter tasks by All, Completed, or Pending states.

Technologies Used:

    1. Jetpack Compose for modern UI development.
    2. Room Database for persistent local storage.
    3. StateFlow for reactive state updates.
    4. ViewModel for managing UI state and logic.

Additional Enhancements:

    1. Unit tests for ViewModel logic using JUnit.
    2. Snackbar notifications for task actions.

Solution Outline

We'll use the following:

  • Jetpack Compose for UI.
  • ViewModel to manage state.
  • StateFlow for reactive state updates.
  • Room Database for local storage.

Code Implementation

// File: TaskManager.kt

package com.example.taskmanager

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.room.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

// Data Model
@Entity(tableName = "tasks")
data class Task(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val title: String,
    val description: String,
    val isCompleted: Boolean = false
)

// DAO
@Dao
interface TaskDao {
    @Insert
    suspend fun insertTask(task: Task)

    @Update
    suspend fun updateTask(task: Task)

    @Delete
    suspend fun deleteTask(task: Task)

    @Query("SELECT * FROM tasks")
    fun getAllTasks(): List<Task>
}

// Database
@Database(entities = [Task::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
}

// ViewModel
class TaskViewModel(private val taskDao: TaskDao) : ViewModel() {
    private val _tasks = MutableStateFlow<List<Task>>(emptyList())
    val tasks: StateFlow<List<Task>> get() = _tasks

    init {
        fetchTasks()
    }

    private fun fetchTasks() {
        viewModelScope.launch {
            _tasks.value = taskDao.getAllTasks()
        }
    }

    fun addTask(title: String, description: String) {
        viewModelScope.launch {
            taskDao.insertTask(Task(title = title, description = description))
            fetchTasks()
        }
    }

    fun toggleCompletion(task: Task) {
        viewModelScope.launch {
            taskDao.updateTask(task.copy(isCompleted = !task.isCompleted))
            fetchTasks()
        }
    }

    fun deleteTask(task: Task) {
        viewModelScope.launch {
            taskDao.deleteTask(task)
            fetchTasks()
        }
    }
}

// ViewModel Factory
class TaskViewModelFactory(private val taskDao: TaskDao) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(TaskViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return TaskViewModel(taskDao) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

// UI with Jetpack Compose
@Composable
fun TaskManagerUI(viewModel: TaskViewModel) {
    val tasks by viewModel.tasks.collectAsState()
    var title by remember { mutableStateOf("") }
    var description by remember { mutableStateOf("") }

    Column(Modifier.padding(16.dp)) {
        Row(Modifier.fillMaxWidth()) {
            BasicTextField(value = title, onValueChange = { title = it }, modifier = Modifier.weight(1f))
            Spacer(modifier = Modifier.width(8.dp))
            BasicTextField(value = description, onValueChange = { description = it }, modifier = Modifier.weight(2f))
            Spacer(modifier = Modifier.width(8.dp))
            Button(onClick = {
                viewModel.addTask(title, description)
                title = ""
                description = ""
            }) {
                Text("Add Task")
            }
        }
        Spacer(modifier = Modifier.height(16.dp))
        LazyColumn {
            items(tasks) { task ->
                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                    Text(task.title)
                    Row {
                        Checkbox(checked = task.isCompleted, onCheckedChange = {
                            viewModel.toggleCompletion(task)
                        })
                        Button(onClick = { viewModel.deleteTask(task) }) {
                            Text("Delete")
                        }
                    }
                }
            }
        }
    }
}

// Entry Point
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "task-db"
        ).build()
        val taskDao = db.taskDao()
        val viewModel: TaskViewModel by viewModels { TaskViewModelFactory(taskDao) }

        setContent {
            TaskManagerUI(viewModel)
        }
    }
}

@Preview(showBackground = true)
@Composable
fun TaskManagerPreview() {
    // Empty preview
    TaskManagerUI(viewModel())
}

Features Highlighted

  1. Jetpack Compose: Declarative and modern UI toolkit for Kotlin.
  2. Room: Manages persistent data.
  3. StateFlow: Reactive state management with Kotlin Coroutines.
  4. ViewModel: Decoupled business logic.

a. Unit Tests for TaskViewModel

Unit tests for TaskViewModel can be implemented using JUnit and a mock of the TaskDao. Here's the code:

// File: TaskViewModelTest.kt

package com.example.taskmanager

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.*
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mockito.*

@OptIn(ExperimentalCoroutinesApi::class)
class TaskViewModelTest {
    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    private val testDispatcher = StandardTestDispatcher()

    private lateinit var mockTaskDao: TaskDao
    private lateinit var taskViewModel: TaskViewModel

    @Before
    fun setUp() {
        Dispatchers.setMain(testDispatcher)
        mockTaskDao = mock(TaskDao::class.java)
        taskViewModel = TaskViewModel(mockTaskDao)
    }

    @Test
    fun `addTask adds a new task and updates task list`() = runTest {
        // Arrange
        val task = Task(title = "Test Task", description = "Test Description")
        `when`(mockTaskDao.getAllTasks()).thenReturn(listOf(task))

        // Act
        taskViewModel.addTask(task.title, task.description)
        advanceUntilIdle()

        // Assert
        val tasks = taskViewModel.tasks.first()
        assertEquals(1, tasks.size)
        assertEquals("Test Task", tasks[0].title)
        verify(mockTaskDao).insertTask(any(Task::class.java))
    }

    @Test
    fun `toggleCompletion toggles the task's completed state`() = runTest {
        // Arrange
        val task = Task(id = 1, title = "Task", description = "Description", isCompleted = false)
        `when`(mockTaskDao.getAllTasks()).thenReturn(listOf(task))

        // Act
        taskViewModel.toggleCompletion(task)
        advanceUntilIdle()

        // Assert
        verify(mockTaskDao).updateTask(task.copy(isCompleted = true))
    }

    @Test
    fun `deleteTask removes the task from the list`() = runTest {
        // Arrange
        val task = Task(id = 1, title = "Task", description = "Description")
        `when`(mockTaskDao.getAllTasks()).thenReturn(listOf())

        // Act
        taskViewModel.deleteTask(task)
        advanceUntilIdle()

        // Assert
        val tasks = taskViewModel.tasks.first()
        assertEquals(0, tasks.size)
        verify(mockTaskDao).deleteTask(task)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }
}

b. Filtering Tasks by State (Completed/Pending)

Add filtering functionality to the UI, using a state variable and the TaskViewModel. Here's the updated code:

Update ViewModel

// File: TaskViewModel.kt

enum class FilterType { ALL, COMPLETED, PENDING }

class TaskViewModel(private val taskDao: TaskDao) : ViewModel() {
    private val _tasks = MutableStateFlow<List<Task>>(emptyList())
    val tasks: StateFlow<List<Task>> get() = _tasks

    private val _filter = MutableStateFlow(FilterType.ALL)

    val filteredTasks = _tasks.combine(_filter) { tasks, filter ->
        when (filter) {
            FilterType.ALL -> tasks
            FilterType.COMPLETED -> tasks.filter { it.isCompleted }
            FilterType.PENDING -> tasks.filter { !it.isCompleted }
        }
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    // Filter setter
    fun setFilter(filter: FilterType) {
        _filter.value = filter
    }
}

Update UI

// File: TaskManagerUI.kt

@Composable
fun TaskManagerUI(viewModel: TaskViewModel) {
    val tasks by viewModel.filteredTasks.collectAsState()
    var title by remember { mutableStateOf("") }
    var description by remember { mutableStateOf("") }

    Column(Modifier.padding(16.dp)) {
        Row(Modifier.fillMaxWidth()) {
            BasicTextField(value = title, onValueChange = { title = it }, modifier = Modifier.weight(1f))
            Spacer(modifier = Modifier.width(8.dp))
            BasicTextField(value = description, onValueChange = { description = it }, modifier = Modifier.weight(2f))
            Spacer(modifier = Modifier.width(8.dp))
            Button(onClick = {
                viewModel.addTask(title, description)
                title = ""
                description = ""
            }) {
                Text("Add Task")
            }
        }
        Spacer(modifier = Modifier.height(16.dp))
        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
            Button(onClick = { viewModel.setFilter(FilterType.ALL) }) { Text("All") }
            Button(onClick = { viewModel.setFilter(FilterType.COMPLETED) }) { Text("Completed") }
            Button(onClick = { viewModel.setFilter(FilterType.PENDING) }) { Text("Pending") }
        }
        Spacer(modifier = Modifier.height(16.dp))
        LazyColumn {
            items(tasks) { task -&gt;
                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                    Text(task.title)
                    Row {
                        Checkbox(checked = task.isCompleted, onCheckedChange = {
                            viewModel.toggleCompletion(task)
                        })
                        Button(onClick = { viewModel.deleteTask(task) }) {
                            Text("Delete")
                        }
                    }
                }
            }
        }
    }
}

Summary of Updates

  1. Unit Tests ensure the ViewModel logic functions correctly.
  2. Filtering adds an interactive feature for better usability.

Suggestions:

a. Refactor filtering logic into a separate function for better modularity.
b. Add a Snackbar to notify users when tasks are added, updated, or deleted.

Happy coding!


What’s your favorite part of this article? Share in the comments below!

Weather App with MVVM Clean Architecture in Kotlin

 Building a weather app is a great way to demonstrate your proficiency in Android development, particularly with modern tools and architecture patterns. In this guide, we’ll walk through building a weather app using MVVM Clean Architecture while leveraging tools like Jetpack Compose, Retrofit, Coroutines, Flow, and Hilt. We'll also include comprehensive testing practices with JUnit, Espresso, and Mockito.  


Project Overview

Goal

Create a weather app where users can:

  • Search for the weather in any U.S. city.
  • Auto-load the last searched city on launch.
  • Display weather icons alongside weather details.
  • Access weather data based on location permissions.

Key Technologies

  • Kotlin
  • MVVM Clean Architecture
  • Retrofit for API calls
  • Jetpack Compose for UI
  • Hilt for dependency injection
  • JUnit, Espresso, Mockito for testing
  • Coroutines and Flow for asynchronous programming
  • Arrow Library for functional programming

Here's an outline and project structure for the requested weather-based Android application using MVVM Clean Architecture. The app will integrate the OpenWeatherMap API, Geocoder API, and fulfill all other requirements.


Project Setup

  1. Gradle dependencies: Add the following to your build.gradle files:
    // Module-level (app)
    plugins {
        id 'com.android.application'
        id 'kotlin-kapt'
        id 'dagger.hilt.android.plugin'
    }
    
    android {
        compileSdk 34
        defaultConfig {
            applicationId "com.example.weatherapp"
            minSdk 21
            targetSdk 34
            versionCode 1
            versionName "1.0"
    
            buildConfigField "String", "BASE_URL", '"https://api.openweathermap.org/data/2.5/"'
            buildConfigField "String", "API_KEY", '"YOUR_API_KEY_HERE"'
        }
    }
    
    dependencies {
        // Core
        implementation "androidx.core:core-ktx:1.12.0"
        implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
        implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1"
        implementation "androidx.activity:activity-compose:1.8.0"
        implementation "androidx.compose.ui:ui:1.6.0"
        implementation "androidx.compose.material3:material3:1.2.0"
    
        // Hilt
        implementation "com.google.dagger:hilt-android:2.48"
        kapt "com.google.dagger:hilt-android-compiler:2.48"
    
        // Retrofit
        implementation "com.squareup.retrofit2:retrofit:2.9.0"
        implementation "com.squareup.retrofit2:converter-gson:2.9.0"
    
        // Coroutines and Flow
        implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
    
        // Testing
        testImplementation "junit:junit:4.13.2"
        androidTestImplementation "androidx.test.ext:junit:1.1.5"
        androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
    
        // Arrow for functional programming
        implementation "io.arrow-kt:arrow-core:1.2.0" 
       
        // Unit Testing
        testImplementation "org.mockito:mockito-core:4.11.0"
        testImplementation "org.mockito.kotlin:mockito-kotlin:4.1.0"
    
        // Coroutines Testing
        testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
    
       // Arrow Testing
       testImplementation "io.arrow-kt:arrow-core:1.2.0"
    
       // Jetpack ViewModel Testing
       testImplementation "androidx.arch.core:core-testing:2.2.0"
    }

Project Structure

com.example.weatherapp
│
├── di                 # Hilt-related classes
│   └── AppModule.kt
│
├── data               # Data layer
│   ├── api
│   │   ├── WeatherApiService.kt
│   │   └── WeatherResponse.kt
│   ├── repository
│       ├── WeatherRepository.kt
│       └── WeatherRepositoryImpl.kt
│
├── domain             # Domain layer
│   ├── model
│   │   └── Weather.kt
│   ├── repository
│   │   └── WeatherRepository.kt
│   ├── usecase
│       └── GetWeatherByCityNameUseCase.kt
│
├── presentation       # UI layer
│   ├── viewmodel
│   │   └── WeatherViewModel.kt
│   └── ui
│       ├── screens
│           └── WeatherScreen.kt
│       └── components
│           └── LoadingState.kt
│
└── utils              # Utility classes
    └── Resource.kt    # For handling state (Loading, Success, Error)

Implementation

1. AppModule.kt (Dependency Injection)

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BuildConfig.BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    @Provides
    @Singleton
    fun provideWeatherApiService(retrofit: Retrofit): WeatherApiService {
        return retrofit.create(WeatherApiService::class.java)
    }

    @Provides
    @Singleton
    fun provideWeatherRepository(api: WeatherApiService): WeatherRepository {
        return WeatherRepositoryImpl(api)
    }
}

2. WeatherApiService.kt

interface WeatherApiService {
    @GET("weather")
    suspend fun getWeatherByCityName(
        @Query("q") cityName: String,
        @Query("appid") apiKey: String = BuildConfig.API_KEY,
        @Query("units") units: String = "metric"
    ): Response<WeatherResponse>
}

3. WeatherRepositoryImpl.kt

class WeatherRepositoryImpl(private val api: WeatherApiService) : WeatherRepository {
    override suspend fun getWeatherByCity(cityName: String): Resource<Weather> {
        return try {
            val response = api.getWeatherByCityName(cityName)
            if (response.isSuccessful) {
                val weatherData = response.body()?.toDomainModel()
                Resource.Success(weatherData)
            } else {
                Resource.Error(response.message())
            }
        } catch (e: Exception) {
            Resource.Error(e.localizedMessage ?: "An unexpected error occurred")
        }
    }
}

4. WeatherViewModel.kt

@HiltViewModel
class WeatherViewModel @Inject constructor(
    private val getWeatherByCityNameUseCase: GetWeatherByCityNameUseCase
) : ViewModel() {

    private val _weatherState = MutableStateFlow<Resource<Weather>>(Resource.Loading())
    val weatherState: StateFlow<Resource<Weather>> get() = _weatherState

    fun fetchWeather(cityName: String) {
        viewModelScope.launch {
            _weatherState.value = Resource.Loading()
            _weatherState.value = getWeatherByCityNameUseCase(cityName)
        }
    }
}

5. WeatherScreen.kt

@Composable
fun WeatherScreen(viewModel: WeatherViewModel = hiltViewModel()) {
    val weatherState by viewModel.weatherState.collectAsState()

    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        TextField(
            value = cityInput,
            onValueChange = { cityInput = it },
            modifier = Modifier.fillMaxWidth(),
            label = { Text("Enter City") }
        )
        Spacer(modifier = Modifier.height(8.dp))
        Button(onClick = { viewModel.fetchWeather(cityInput) }) {
            Text("Get Weather")
        }
        Spacer(modifier = Modifier.height(16.dp))

        when (weatherState) {
            is Resource.Loading -> LoadingState()
            is Resource.Success -> WeatherDetails(weather = weatherState.data)
            is Resource.Error -> ErrorState(message = weatherState.message)
        }
    }
}

Features Included

  1. Auto-search with debounce (handled using StateFlow and TextField events in Compose).
  2. Location permission handling (using ActivityCompat.requestPermissions).
  3. Weather icon rendering (from OpenWeatherMap's image URLs).
  4. Proper error and loading states using the Resource class.

Testing

1. Unit Test Example: WeatherRepositoryImpl

Test how the repository handles API responses using Mockito.

@ExperimentalCoroutinesApi
class WeatherRepositoryImplTest {

    @Mock
    private lateinit var mockApiService: WeatherApiService

    private lateinit var repository: WeatherRepository

    @get:Rule
    val coroutineRule = MainCoroutineRule() // Custom rule to manage coroutine scope

    @Before
    fun setup() {
        MockitoAnnotations.openMocks(this)
        repository = WeatherRepositoryImpl(mockApiService)
    }

    @Test
    fun `getWeatherByCity returns success`() = runTest {
        // Given
        val cityName = "Boston"
        val mockResponse = WeatherResponse(/* Populate with mock data */)
        Mockito.`when`(mockApiService.getWeatherByCityName(cityName)).thenReturn(Response.success(mockResponse))

        // When
        val result = repository.getWeatherByCity(cityName)

        // Then
        assert(result is Resource.Success)
        assertEquals(mockResponse.toDomainModel(), (result as Resource.Success).data)
    }

    @Test
    fun `getWeatherByCity returns error on exception`() = runTest {
        // Given
        val cityName = "Unknown"
        Mockito.`when`(mockApiService.getWeatherByCityName(cityName)).thenThrow(RuntimeException("Network error"))

        // When
        val result = repository.getWeatherByCity(cityName)

        // Then
        assert(result is Resource.Error)
        assertEquals("Network error", (result as Resource.Error).message)
    }
}

2. Unit Test Example: WeatherViewModel

Test how the ViewModel handles states.

@ExperimentalCoroutinesApi
class WeatherViewModelTest {

    @Mock
    private lateinit var mockUseCase: GetWeatherByCityNameUseCase

    private lateinit var viewModel: WeatherViewModel

    @get:Rule
    val coroutineRule = MainCoroutineRule()

    @Before
    fun setup() {
        MockitoAnnotations.openMocks(this)
        viewModel = WeatherViewModel(mockUseCase)
    }

    @Test
    fun `fetchWeather sets loading and success states`() = runTest {
        // Given
        val cityName = "Los Angeles"
        val mockWeather = Weather(/* Populate with mock data */)
        Mockito.`when`(mockUseCase(cityName)).thenReturn(Resource.Success(mockWeather))

        // When
        viewModel.fetchWeather(cityName)

        // Then
        val states = viewModel.weatherState.take(2).toList()
        assert(states[0] is Resource.Loading)
        assert(states[1] is Resource.Success &amp;&amp; states[1].data == mockWeather)
    }

    @Test
    fun `fetchWeather sets error state`() = runTest {
        // Given
        val cityName = "InvalidCity"
        val errorMessage = "City not found"
        Mockito.`when`(mockUseCase(cityName)).thenReturn(Resource.Error(errorMessage))

        // When
        viewModel.fetchWeather(cityName)

        // Then
        val states = viewModel.weatherState.take(2).toList()
        assert(states[0] is Resource.Loading)
        assert(states[1] is Resource.Error &amp;&amp; states[1].message == errorMessage)
    }
}

UI Testing

1. Setup Dependencies

In build.gradle:

// UI Testing
androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
androidTestImplementation "androidx.test.ext:junit:1.1.5"

// Jetpack Compose Testing
androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.6.0"
debugImplementation "androidx.compose.ui:ui-test-manifest:1.6.0"

2. UI Test Example: WeatherScreen

Use Compose's testing APIs to check interactions and states.

@HiltAndroidTest
@UninstallModules(AppModule::class) // Mock dependencies if needed
class WeatherScreenTest {

    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val composeRule = createAndroidComposeRule&lt;MainActivity&gt;() // Replace with the app's main activity

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun enterCityAndFetchWeather_displaysWeatherInfo() {
        // Given
        val cityName = "San Francisco"

        // When
        composeRule.onNodeWithTag("CityInputField").performTextInput(cityName)
        composeRule.onNodeWithTag("FetchWeatherButton").performClick()

        // Then
        composeRule.onNodeWithText("Weather Info").assertExists()
    }

    @Test
    fun fetchWeather_displaysLoadingAndErrorState() {
        // Simulate API response delay and error
        composeRule.onNodeWithTag("CityInputField").performTextInput("InvalidCity")
        composeRule.onNodeWithTag("FetchWeatherButton").performClick()

        // Check loading state
        composeRule.onNodeWithTag("LoadingIndicator").assertExists()

        // After API call fails
        composeRule.waitUntil { composeRule.onNodeWithTag("ErrorText").fetchSemanticsNode() != null }
        composeRule.onNodeWithTag("ErrorText").assertTextContains("City not found")
    }
}

3. Testing Tips

  • Use mock responses for network requests during UI tests (e.g., using MockWebServer).
  • Add tags (Modifier.testTag()) to Composable elements for easier identification in tests.
  • Use coroutines test dispatchers for consistent timing in tests.

Conclusion

This weather app demonstrates clean architecture principles and incorporates modern Android tools and practices. By focusing on separation of concerns, defensive coding, and robust testing, you can create a scalable and maintainable app.

Sample Output

When a user enters "New York" and clicks the search button:

  • A loading spinner appears.
  • The app fetches weather details via the API.
  • The UI updates with temperature, humidity, and a weather icon.

Happy coding!


If you faced any kind of error or problems? Share in the comments below!

Background Tasks in Android: Enhancing App Performance

Modern Android applications often require tasks to be executed in the background, such as fetching data from APIs, syncing local data with a server, or processing complex computations without disrupting the user interface. Background tasks are a cornerstone of creating smooth and responsive user experiences while maximizing app performance.

This article explores the primary mechanisms for implementing background tasks in Android, the best use cases for each, and example code snippets.




Why Use Background Tasks?

Key Benefits:

  • Improved User Experience: Long-running or resource-intensive tasks are offloaded from the main thread to avoid app freezes or crashes.
  • Resource Optimization: By handling tasks asynchronously, resources like CPU and memory are better utilized.
  • Seamless Multitasking: Applications can perform multiple tasks simultaneously.

Options for Background Tasks in Android

1. Threads

The simplest way to execute a background task is using a plain Java thread.

Use Case:

  • Quick, short-lived operations.
  • Suitable for legacy applications.

Example:

Thread {
    // Simulate a background task
    Thread.sleep(2000)
    Log.d("BackgroundTask", "Task completed!")
}.start()

Limitations:

  • Not lifecycle-aware.
  • Manual thread management is error-prone.

2. AsyncTask (Deprecated)

AsyncTask was a common solution for background tasks but has been deprecated due to issues like memory leaks and poor lifecycle handling.


3. ExecutorService

A more robust option than Threads, ExecutorService manages a pool of threads and is ideal for running multiple background tasks.

Use Case:

  • Multiple tasks requiring thread pooling.

Example:

val executor = Executors.newFixedThreadPool(3)
executor.execute {
    Log.d("ExecutorService", "Task executed in the background")
}
executor.shutdown()

Limitations:

  • Not lifecycle-aware.
  • Requires manual thread management.

4. HandlerThread

HandlerThread provides a thread with a message loop, making it easier to communicate between threads.

Use Case:

  • Background tasks requiring periodic communication with the main thread.

Example:

val handlerThread = HandlerThread("BackgroundThread")
handlerThread.start()

val handler = Handler(handlerThread.looper)
handler.post {
    // Background work
    Log.d("HandlerThread", "Background task running")
}

Limitations:

  • Not suitable for long-running tasks.

5. WorkManager

WorkManager is the modern solution for background tasks that require guaranteed execution. It supports constraints like network connectivity, charging status, etc.

Use Case:

  • Tasks requiring guaranteed execution, even after app restarts.
  • Suitable for tasks like syncing data or sending logs to a server.

Example:

class MyWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) {
    override fun doWork(): Result {
        // Background task
        Log.d("WorkManager", "Executing task in background")
        return Result.success()
    }
}

// Schedule the work
val workRequest = OneTimeWorkRequestBuilder<MyWorker>().build()
WorkManager.getInstance(context).enqueue(workRequest)

Advantages:

  • Lifecycle-aware.
  • Handles constraints effectively.
  • Recommended for long-running and deferred tasks.

6. Coroutines

Coroutines provide a modern, lightweight solution for handling background tasks. With structured concurrency, they are both efficient and easy to manage.

Use Case:

  • Complex asynchronous tasks.
  • Tasks tightly coupled with UI (e.g., fetching data from APIs).

Example:

fun fetchData() {
    CoroutineScope(Dispatchers.IO).launch {
        val data = fetchDataFromNetwork()
        withContext(Dispatchers.Main) {
            Log.d("Coroutines", "Data fetched: $data")
        }
    }
}

Advantages:

  • Lifecycle-aware when paired with ViewModel and LiveData.
  • Simplifies asynchronous programming.

7. JobScheduler

JobScheduler schedules background tasks that run based on conditions like device charging or network availability.

Use Case:

  • System-level background tasks (e.g., periodic updates).

Example:

val jobScheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
val jobInfo = JobInfo.Builder(1, ComponentName(this, MyJobService::class.java))
    .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
    .setRequiresCharging(true)
    .build()

jobScheduler.schedule(jobInfo)

Limitations:

  • API 21+ required.
  • Less flexible compared to WorkManager.

8. Foreground Services

Foreground services are used for tasks requiring user attention, such as music playback or location tracking.

Use Case:

  • Continuous tasks requiring a persistent notification.

Example:

class MyForegroundService : Service() {
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val notification = NotificationCompat.Builder(this, "channel_id")
            .setContentTitle("Foreground Service")
            .setContentText("Task running")
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .build()

        startForeground(1, notification)

        return START_STICKY
    }
}

Choosing the Best Option

Mechanism Best Use Case Lifecycle-Aware
Threads Simple, quick tasks No
ExecutorService Thread pooling No
HandlerThread Communication between threads No
WorkManager Guaranteed, long-running tasks Yes
Coroutines Lightweight tasks, async UI updates Yes
JobScheduler System-level tasks with conditions No
Foreground Service Continuous tasks requiring persistent notification No

Conclusion

For most modern Android apps, WorkManager and Coroutines are the go-to solutions for implementing background tasks. WorkManager is ideal for guaranteed, deferred tasks with constraints, while Coroutines are perfect for lightweight asynchronous tasks.

By choosing the right mechanism, you can create efficient and performant Android applications that deliver excellent user experiences.


What’s Next?

Explore the official Android documentation for deeper insights and best practices for background tasks. If you have unique requirements, combining these tools can also lead to innovative solutions.