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.

How to Implement Ktor in Android Apps Using MVVM Clean Architecture, Jetpack Compose, and Kotlin Coroutines

Ktor is a Kotlin-native framework that enables developers to create asynchronous HTTP clients and servers. In this article, we'll walk you through implementing Ktor in an Android project structured around MVVM Clean Architecture, leveraging Jetpack Compose for UI and Kotlin Coroutines and Flow for data handling.



Prerequisites

Before starting, ensure you are familiar with:

  • Kotlin programming
  • MVVM Clean Architecture
  • Jetpack Compose for UI
  • Dependency Injection with Hilt
  • Coroutines and Flows

Step 1: Set Up Your Android Project

  1. Create a new Android project in Android Studio.

    • Choose Jetpack Compose in the project setup wizard.
    • Select Kotlin as the programming language.
  2. Add Dependencies
    Open your app/build.gradle file and include the following libraries:

    
dependencies {
    // Ktor for HTTP requests
    implementation("io.ktor:ktor-client-core:2.0.0")
    implementation("io.ktor:ktor-client-cio:2.0.0")
    implementation("io.ktor:ktor-client-content-negotiation:2.0.0")
    implementation("io.ktor:ktor-serialization-kotlinx-json:2.0.0")

    // Kotlin Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")

    // Jetpack Compose
    implementation("androidx.compose.ui:ui:1.5.0")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")

    // Hilt for Dependency Injection
    implementation("com.google.dagger:hilt-android:2.47")
    kapt("com.google.dagger:hilt-compiler:2.47")

    // Testing libraries (optional)
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
}
  1. Enable Kotlin Serialization
    In your build.gradle (project-level), enable the Kotlin serialization plugin:
plugins {
    id("org.jetbrains.kotlin.plugin.serialization") version "1.9.10"
}

Step 2: Define the Clean Architecture Layers

Clean Architecture organizes the code into layers to improve scalability and maintainability. These layers include Presentation, Domain, and Data.


Data Layer

The Data Layer handles communication with APIs and maps responses into domain models.

  1. Define API Endpoints

Create a KtorService interface to abstract Ktor HTTP calls:

interface KtorService {
    suspend fun fetchItems(): List<ItemDto>
}
  1. Implement KtorService

Here, we configure Ktor and handle the API interaction:

class KtorServiceImpl : KtorService {
    private val client = HttpClient(CIO) {
        install(ContentNegotiation) {
            json() // Enable JSON serialization
        }
    }

    override suspend fun fetchItems(): List&lt;ItemDto&gt; {
        return client.get("https://api.example.com/items").body()
    }
}
  1. Define DTO and Mappers

Define a Data Transfer Object (DTO) to represent API responses and map it to a domain model.

@Serializable
data class ItemDto(
    val id: String,
    val name: String
)

fun ItemDto.toDomain(): Item = Item(id, name)
  1. Repository

Create a repository to abstract data sources and expose flows:

class ItemRepository(private val service: KtorService) {
    suspend fun getItems(): Flow<List<Item>> = flow {
        try {
            val response = service.fetchItems()
            emit(response.map { it.toDomain() }) // Convert DTO to domain model
        } catch (e: Exception) {
            emit(emptyList()) // Handle errors gracefully
        }
    }
}

Domain Layer

The Domain Layer contains business logic and is independent of the framework.

  1. Define Domain Model

Create the domain model for your app:

data class Item(
    val id: String,
    val name: String
)
  1. Implement Use Case

A use case encapsulates a single piece of functionality:

class GetItemsUseCase(private val repository: ItemRepository) {
    operator fun invoke(): Flow<List<Item>> {
        return repository.getItems()
    }
}

Presentation Layer

The Presentation Layer manages UI state and user interactions.

  1. Create ViewModel

Use ViewModel to expose data to the UI:

@HiltViewModel
class ItemViewModel @Inject constructor(
    private val getItemsUseCase: GetItemsUseCase
) : ViewModel() {
    private val _itemsState = MutableStateFlow<List<Item>>(emptyList())
    val itemsState: StateFlow<List<Item>> = _itemsState

    init {
        fetchItems()
    }

    private fun fetchItems() {
        viewModelScope.launch {
            getItemsUseCase().collect { items ->
                _itemsState.value = items
            }
        }
    }
}
  1. Compose UI

Use Jetpack Compose to create the UI:

@Composable
fun ItemListScreen(viewModel: ItemViewModel = hiltViewModel()) {
    val items by viewModel.itemsState.collectAsState()

    LazyColumn {
        items(items) { item ->
            Text(
                text = item.name,
                style = MaterialTheme.typography.body1,
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}

Step 3: Set Up Dependency Injection

Use Hilt to inject dependencies across layers.

  1. Setup Hilt

Annotate your application class:

@HiltAndroidApp
class MyApp : Application()
  1. Provide Dependencies

Create an Hilt module:

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    fun provideKtorService(): KtorService = KtorServiceImpl()

    @Provides
    fun provideRepository(service: KtorService): ItemRepository = ItemRepository(service)

    @Provides
    fun provideGetItemsUseCase(repository: ItemRepository): GetItemsUseCase = GetItemsUseCase(repository)
}

Step 4: Handle State and Error Management

In a real-world app, you must handle API states (loading, success, error) gracefully.

  1. Update ViewModel

Add a state to track the API status:

data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

@HiltViewModel
class ItemViewModel @Inject constructor(
    private val getItemsUseCase: GetItemsUseCase
) : ViewModel() {
    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState> = _uiState

    init {
        fetchItems()
    }

    private fun fetchItems() {
        viewModelScope.launch {
            _uiState.value = UiState(isLoading = true)
            getItemsUseCase().collect { items ->
                _uiState.value = UiState(items = items)
            }
        }
    }
}
  1. Compose UI with State

Display states in the UI:

@Composable
fun ItemListScreen(viewModel: ItemViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsState()

    when {
        state.isLoading -> CircularProgressIndicator()
        state.error != null -> Text("Error: ${state.error}")
        else -> LazyColumn {
            items(state.items) { item ->
                Text(text = item.name)
            }
        }
    }
}

Step 5: Test Your App

Run the app and verify that:

  • Items load from the API.
  • UI updates automatically when data changes.

Conclusion

By combining Ktor, MVVM Clean Architecture, Jetpack Compose, and Kotlin Coroutines, you create a scalable, testable, and maintainable Android app. Expand on this foundation by adding advanced features like offline caching, user authentication, or detailed error reporting.

Happy coding! 🚀

Building a High-Performance Banking Android App: An Android Engineer’s Guide

The rise of mobile banking has shifted the way customers interact with financial institutions, making banking apps a critical touchpoint for users. A modern banking app requires a blend of cutting-edge technology, secure operations, and an intuitive user experience. For Senior Android Engineers, creating such apps means leveraging the best tools, frameworks, and practices to ensure security, scalability, and performance.

This article delves into the technical foundations of building a sophisticated banking Android application using Kotlin, Coroutines with Flow, REST APIs, Jetpack Compose, and MVVM Clean Architecture, all backed by Test-Driven Development (TDD) practices and robust data security mechanisms.




Core Features of the Banking App

Functional Features

  • User Authentication: Supports biometric login, PIN, or multi-factor authentication for secure access.
  • Account Management: View balances, transaction history, and account statements.
  • Fund Transfers: Real-time transfers, scheduled payments, and bill payments.
  • Notifications: Real-time alerts for transactions and updates.

Non-Functional Features

  • Security: Encryption for sensitive data and secure API communication.
  • Performance: Fast response times and smooth user interactions, even with large data sets.
  • Accessibility: Design adhering to WCAG standards for a wide user base.
  • Scalability: Modular and maintainable code for future feature enhancements.

Development Process

1. Tech Stack Overview

The following stack ensures efficiency and aligns with modern Android development standards:

  • Kotlin: A robust, concise, and feature-rich language for Android development.
  • Jetpack Compose: For building dynamic, declarative UIs.
  • MVVM Clean Architecture: To separate concerns and enhance testability.
  • Retrofit with Coroutines and Flow: For seamless REST API integration and reactive data flows.
  • Hilt: Dependency injection for better code management.
  • Room: Database for caching and offline support.

2. Architecture: MVVM Clean Architecture

Separation of Concerns:

  • Presentation Layer: Jetpack Compose-driven UI interacts with ViewModels.
  • Domain Layer: Business logic encapsulated in Use Cases ensures modularity.
  • Data Layer: Manages API calls, local storage, and other data sources via repositories.

This architecture promotes reusability and scalability while keeping the codebase clean and maintainable.

Example: MVVM workflow

  1. User triggers an action (e.g., taps “View Balance”).
  2. ViewModel fetches data via a Use Case.
  3. Repository retrieves data from a REST API or Room database.
  4. UI updates automatically based on the ViewModel's state.

3. Networking with Retrofit, Coroutines, and Flow

To ensure reliability and real-time updates, the app uses Retrofit with Coroutines and Flow.

Key Implementation Details:

  • Use Retrofit for REST API communication.
  • Use Coroutines for background tasks to avoid blocking the main thread.
  • Flow ensures efficient data streams for state management.

Example: Fetching account transactions

@GET("accounts/transactions")  
suspend fun getTransactions(): Response<List<Transaction>>  

class TransactionRepository(private val api: BankingApi) {  
    fun fetchTransactions(): Flow<Result<List<Transaction>>> = flow {  
        emit(Result.Loading)  
        try {  
            val response = api.getTransactions()  
            if (response.isSuccessful) {  
                emit(Result.Success(response.body()!!))  
            } else {  
                emit(Result.Error(Exception("Failed to fetch transactions")))  
            }  
        } catch (e: Exception) {  
            emit(Result.Error(e))  
        }  
    }  
} 

Example: Fetching account balances

@GET("accounts/balance")  
suspend fun getAccountBalance(): Response<AccountBalance>  

class AccountRepository(private val api: BankingApi) {  
    fun fetchAccountBalance(): Flow<Result<AccountBalance>> = flow {  
        emit(Result.Loading)  
        try {  
            val response = api.getAccountBalance()  
            if (response.isSuccessful) {  
                emit(Result.Success(response.body()!!))  
            } else {  
                emit(Result.Error(Exception("Error fetching balance")))  
            }  
        } catch (e: Exception) {  
            emit(Result.Error(e))  
        }  
    }  
}  

4. Building Dynamic UIs with Jetpack Compose

Jetpack Compose enables declarative UI development, simplifying the creation of dynamic components.

Advantages:

  • Simplifies handling complex UI states.
  • Reduces boilerplate code compared to XML layouts.
  • Integrates seamlessly with the MVVM pattern.

Example: Composable for transaction history

@Composable  
fun TransactionListScreen(viewModel: TransactionViewModel) {  
    val transactions = viewModel.transactionState.collectAsState()  

    LazyColumn {  
        items(transactions.value) { transaction ->  
            TransactionItem(transaction)  
        }  
    }  
}  

@Composable  
fun TransactionItem(transaction: Transaction) {  
    Row(Modifier.padding(8.dp)) {  
        Text("Date: ${transaction.date}", Modifier.weight(1f))  
        Text("Amount: \$${transaction.amount}", Modifier.weight(1f))  
    }  
}

Example: Displaying account balance

@Composable  
fun AccountBalanceScreen(viewModel: AccountViewModel) {  
    val state = viewModel.balanceState.collectAsState()  

    when (state.value) {  
        is Result.Loading -> CircularProgressIndicator()  
        is Result.Success -> Text("Balance: \$${(state.value as Result.Success).data}")  
        is Result.Error -> Text("Error: ${(state.value as Result.Error).exception.message}")  
    }  
}  


5. Dependency Injection with Hilt

Hilt simplifies dependency management by providing lifecycle-aware components.

Implementation:

  • Add Hilt annotations (@HiltAndroidApp, @Inject, etc.) for seamless integration.
  • Manage dependencies like repositories, ViewModels, and APIs through Hilt modules.

Example: Hilt Module for API and Repository

@Module  
@InstallIn(SingletonComponent::class)  
object AppModule {  
    @Provides  
    fun provideBankingApi(): BankingApi = Retrofit.Builder()  
        .baseUrl(BASE_URL)  
        .addConverterFactory(GsonConverterFactory.create())  
        .build()  
        .create(BankingApi::class.java)  

    @Provides  
    fun provideTransactionRepository(api: BankingApi): TransactionRepository =  
        TransactionRepository(api)  
}
@HiltViewModel  
class AccountViewModel @Inject constructor(  
    private val repository: AccountRepository  
) : ViewModel() {  
    val balanceState = repository.fetchAccountBalance().stateIn(  
        viewModelScope, SharingStarted.Lazily, Result.Loading  
    )  
}  

6. Ensuring Security

Security Measures:

  • Encrypted Storage: Protect sensitive data like tokens and PINs using EncryptedSharedPreferences.
  • Network Security: Use HTTPS with strict SSL validation and enable Network Security Config.
  • Authentication: Enforce biometric login using Android’s Biometric API.

Example: Biometric Authentication Setup

val biometricPrompt = BiometricPrompt(  
    activity,  
    Executors.newSingleThreadExecutor(),  
    object : BiometricPrompt.AuthenticationCallback() {  
        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {  
            // Proceed with secure actions  
        }  
    }  
)  

val promptInfo = BiometricPrompt.PromptInfo.Builder()  
    .setTitle("Secure Login")  
    .setDescription("Use your fingerprint to login")  
    .setNegativeButtonText("Cancel")  
    .build()  

biometricPrompt.authenticate(promptInfo)  

7. Test-Driven Development (TDD)

Testing Strategy:

  • Unit Testing: Test business logic in ViewModels and Use Cases using JUnit and Mockito.
  • UI Testing: Validate UI interactions using Espresso.
  • Integration Testing: Ensure seamless communication between components.

Example: Unit Test for ViewModel

@Test  
fun `fetchTransactions emits success state`() = runTest {  
    val fakeRepository = FakeTransactionRepository()  
    val viewModel = TransactionViewModel(fakeRepository)  

    viewModel.fetchTransactions()  
    assertTrue(viewModel.transactionState.value is Result.Success)  
} 
Testing Tools:
  • JUnit: Unit tests for ViewModel and Use Cases.
  • Mockito: Mock dependencies in tests.
  • Espresso: UI testing for Compose components.

Sample Unit Test with Mockito

@Test  
fun `fetchAccountBalance returns success`() = runTest {  
    val mockApi = mock(BankingApi::class.java)  
    `when`(mockApi.getAccountBalance()).thenReturn(Response.success(mockBalance))  

    val repository = AccountRepository(mockApi)  
    val result = repository.fetchAccountBalance().first()  
    assertTrue(result is Result.Success)  
}  

8. Performance Optimization

Best Practices:

  • Lazy Loading: Use LazyColumn to load large datasets efficiently.
  • Debouncing: Reduce redundant API calls during search input.
  • Caching: Implement local caching for offline access using Room.

Example: Implementing Search Debouncing with Flow

val searchQuery = MutableStateFlow("")  
searchQuery  
    .debounce(300)  
    .flatMapLatest { query -> repository.searchTransactions(query) }  
    .collect { result -> updateUI(result) }  

Conclusion

Developing a banking Android app is a challenging yet rewarding task, requiring careful attention to security, performance, and user experience. By adopting Kotlin, Jetpack Compose, MVVM Clean Architecture, and robust testing practices, you can create an app that is not only secure and efficient but also future-proof and maintainable.

For Senior Android Engineers, staying updated with modern development trends and tools is key to delivering impactful and high-quality banking applications.

Dealing with INSTALL_FAILED_INSUFFICIENT_STORAGE in Android Studio: Tips and Solutions (2024)

As of November 2024 in Android Studio, the INSTALL_FAILED_INSUFFICIENT_STORAGE error persists as a common issue encountered when there's not enough storage space on either the physical device or the emulator. However, Android Studio has introduced additional tools and features that can assist in better managing app storage and resolving these errors.



Causes of INSTALL_FAILED_INSUFFICIENT_STORAGE Error (Nov 2024)

  1. Insufficient Storage on Device or Emulator:

    • The available storage space on the device or emulator might be too low to accommodate the installation of the APK or app bundle.
  2. Large APK or App Bundle Size:

    • The app size could be too large for the available storage, especially if your app includes large resources (images, videos, etc.).
  3. Leftover Data or Cache:

    • Unused or accumulated data, especially from previous app installations, could take up storage space, causing installation to fail.
  4. Storage Management in Emulator:

    • The default virtual storage settings in the emulator might not be large enough to handle the installation of large applications.

Steps to Resolve INSTALL_FAILED_INSUFFICIENT_STORAGE in Android Studio (Nov 2024)

1. Check Available Storage on Device or Emulator

For Physical Devices:

  • Open Settings > Storage on the device to check the available storage.
  • Clear space by deleting unnecessary files, apps, or media.

For Emulators:

  • Increase the Emulator's Storage:
    1. Go to Tools > AVD Manager in Android Studio.
    2. Select your active Virtual Device and click the Edit (pencil) icon.
    3. Increase the Internal Storage size (e.g., 2GB or more) in the Advanced Settings section.
    4. Click Finish to apply the changes and try installing the app again.

ADB Command (for Devices and Emulators):

  • You can also use the following ADB command to check the available storage space:
    adb shell df
    
  • This will show the disk usage across partitions (e.g., /data).

2. Clear Cache or Uninstall Previous Apps (on the Device or Emulator)

  • Clear Cache and Data for apps that might be consuming space:

    1. Go to Settings > Apps.
    2. Select the app causing the issue and click on Storage.
    3. Tap on Clear Cache and Clear Data.
  • Uninstall Unnecessary Apps or media files (images, videos) from the device or emulator.

3. Optimize APK Size

For APKs:

  • If the APK is too large, consider using Android App Bundles (AAB) instead, as they provide more efficient packaging for delivery, reducing the size per device.
    • Android App Bundle splits your APKs by device configuration and allows Android to dynamically serve only the parts required for the device.
  • Use the Build > Analyze APK feature in Android Studio to check the APK’s size and reduce unnecessary resources.

Other APK Optimization Techniques:

  • ProGuard/R8 Minification: Reduce the size of your app by removing unused code.

    • In build.gradle, enable code shrinking:
      buildTypes {
          release {
              minifyEnabled true
              shrinkResources true
          }
      }
  • Compress Images: Convert images to more efficient formats like WebP.

  • Remove Unused Resources: Remove unused resources like images or layouts that aren’t part of the app.

4. Use Android App Bundles (AAB)

Android Studio now strongly encourages using Android App Bundles (AAB) for distribution over traditional APKs.

  • Benefits:
    • It allows Google Play to generate optimized APKs for different device configurations (screen size, architecture, etc.), drastically reducing the app size.
    • It's now the default format for apps published on the Google Play Store.
  • To build an AAB in Android Studio:
    1. Go to Build > Build Bundle / APK.
    2. Select Build Bundle.

If you haven’t migrated to AAB, this might be a good time, as it can help address storage-related issues.

5. Clear Old App Data or Artifacts

For Physical Devices:

  • If you’re re-installing the app multiple times or iterating on your app, there may be old data or build artifacts causing storage issues.
  • Uninstall the App and reinstall to clear old data.

For Emulators:

  • Sometimes snapshots or old builds in the emulator can cause storage issues.
    • Go to AVD Manager and Wipe Data or Cold Boot the emulator to reset it.

6. Check and Use ADB Tools for Storage Debugging

Use ADB to check the partition status and storage usage:

 
adb shell dumpsys diskstats

This command provides detailed information about disk usage and can help you identify what might be taking up space.

7. Android Studio Updates and Storage Tools (Nov 2024)

Android Studio (November 2024) now provides:

  • Profiler Tools: Use the Profiler tab to monitor the app’s resource consumption, which can help identify large assets or inefficient code.
  • Better Emulator Management: Android Studio offers advanced tools to configure your emulator’s resources, including disk space, RAM, and CPU.

New Emulator Features (Nov 2024):

  • Dynamic Storage Allocation: Android Emulator has a feature that dynamically adjusts storage allocation depending on the requirements of the app.
  • Snapshot Management: Improved snapshot management allows you to save and restore emulator states without consuming unnecessary storage.

Conclusion

The INSTALL_FAILED_INSUFFICIENT_STORAGE error in Android Studio (Nov 2024) can be resolved by freeing up space on your device/emulator, optimizing your app's size (using AAB, minimizing resources, etc.), and leveraging Android Studio's improved storage management tools for emulators. If your emulator runs into storage limits, consider increasing the emulator's storage size in the AVD Manager and managing build artifacts effectively.