Showing posts with label Android. Show all posts
Showing posts with label Android. Show all posts

Part 2: Hardware-Backed Encrypted DataStore in Android

 Alright — let’s make Part 2 of this blog: a full hardware-backed Encrypted DataStore implementation for production use.

We’ll combine:

  • Jetpack DataStore (Preferences) for safe, async storage

  • Hardware-backed AES key in Android Keystore (StrongBox when available)

  • AES/GCM/NoPadding encryption with IV handling

  • Easy API for saving/reading/deleting sensitive strings



Part 2: Hardware-Backed Encrypted DataStore in Android


1. Why Combine Hardware-Backed Keys with DataStore?

From Part 1, we learned that hardware-backed encryption ensures that encryption keys never leave secure hardware and can’t be extracted.

DataStore is the modern alternative to SharedPreferences:

  • Asynchronous (no ANRs)

  • Type-safe

  • Corruption-handling

  • Flow-based API

By encrypting all values before storing them in DataStore — with a hardware-backed AES key — we get:

  • Encryption at rest + secure key storage

  • Resilience against root and file dump attacks

  • Modern, maintainable API


2. Dependencies

Add to your build.gradle:

dependencies {
    implementation "androidx.datastore:datastore-preferences:1.1.1"
}

No extra crypto libraries are needed — we’ll use Android’s built-in Keystore and javax.crypto.


3. Crypto Helper (Hardware-Backed)

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import android.util.Base64
import java.security.SecureRandom

object HardwareCrypto {
    private const val KEY_ALIAS = "app_secure_datastore_key"
    private const val ANDROID_KEYSTORE = "AndroidKeyStore"
    private const val TRANSFORMATION = "AES/GCM/NoPadding"
    private const val IV_SIZE_BYTES = 12
    private const val TAG_LENGTH_BITS = 128

    fun getOrCreateKey(): SecretKey {
        val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
        val existingKey = keyStore.getKey(KEY_ALIAS, null) as? SecretKey
        if (existingKey != null) return existingKey

        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
        val spec = KeyGenParameterSpec.Builder(
            KEY_ALIAS,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
        )
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setKeySize(256)
            .setIsStrongBoxBacked(true) // Use StrongBox if available
            .build()

        keyGenerator.init(spec)
        return keyGenerator.generateKey()
    }

    fun encrypt(secretKey: SecretKey, plainText: String): String {
        val cipher = Cipher.getInstance(TRANSFORMATION)
        val iv = ByteArray(IV_SIZE_BYTES).also { SecureRandom().nextBytes(it) }
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH_BITS, iv))
        val cipherBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
        return Base64.encodeToString(iv + cipherBytes, Base64.NO_WRAP)
    }

    fun decrypt(secretKey: SecretKey, encryptedBase64: String): String {
        val decoded = Base64.decode(encryptedBase64, Base64.NO_WRAP)
        val iv = decoded.copyOfRange(0, IV_SIZE_BYTES)
        val cipherBytes = decoded.copyOfRange(IV_SIZE_BYTES, decoded.size)
        val cipher = Cipher.getInstance(TRANSFORMATION)
        cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH_BITS, iv))
        return String(cipher.doFinal(cipherBytes), Charsets.UTF_8)
    }
}

4. Encrypted DataStore Manager

import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.preferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.crypto.SecretKey

private val Context.secureDataStore by preferencesDataStore(name = "secure_prefs")

class EncryptedDataStoreManager(private val context: Context) {

    private val secretKey: SecretKey by lazy { HardwareCrypto.getOrCreateKey() }

    suspend fun saveString(key: String, value: String) {
        val encrypted = HardwareCrypto.encrypt(secretKey, value)
        context.secureDataStore.edit { prefs ->
            prefs[preferencesKey<String>(key)] = encrypted
        }
    }

    fun readString(key: String): Flow<String?> {
        return context.secureDataStore.data.map { prefs ->
            prefs[preferencesKey<String>(key)]?.let {
                try { HardwareCrypto.decrypt(secretKey, it) } catch (e: Exception) { null }
            }
        }
    }

    suspend fun removeKey(key: String) {
        context.secureDataStore.edit { prefs ->
            prefs.remove(preferencesKey<String>(key))
        }
    }

    suspend fun clearAll() {
        context.secureDataStore.edit { it.clear() }
    }
}

5. Example Usage

val secureStore = EncryptedDataStoreManager(context)

// Saving
lifecycleScope.launch {
    secureStore.saveString("auth_token", "super_secret_token_123")
}

// Reading
lifecycleScope.launch {
    secureStore.readString("auth_token").collect { token ->
        println("Decrypted token: $token")
    }
}

6. Benefits of This Approach

  • Hardware-backed keys protect encryption keys at the hardware level

  • Asynchronous DataStore prevents ANRs

  • AES-256 GCM provides confidentiality + integrity verification

  • StrongBox support ensures even higher security on compatible devices

  • Simple API for engineers to integrate


7. Final Thoughts

If your app handles any sensitive data — authentication tokens, API secrets, offline cached PII — you should never store it in plain text. Combining hardware-backed keys with modern DataStore gives you an end-to-end secure storage layer that’s:

  • Modern

  • Maintainable

  • Resistant to common mobile security threats

In a security audit, this design will be a strong point in your architecture.


πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»

Why Hardware-Backed Encryption Matters in Android Security (with Example)

When storing sensitive information in Android apps — such as authentication tokens, API keys, or personally identifiable information (PII) — it’s not enough to “just encrypt it.”

Where and how encryption keys are stored is as important as encrypting the data itself. If your encryption keys can be extracted from the device, your encryption is essentially useless.

That’s where hardware-backed encryption in Android comes into play.


2. What is Hardware-Backed Encryption?

Android devices often include a Trusted Execution Environment (TEE) or Secure Element (SE) — an isolated, secure chip separate from the main CPU.

When you generate encryption keys with the Android Keystore and request them to be hardware-backed, the keys:

  • Are generated inside secure hardware

  • Never leave the hardware in plaintext form

  • Are used directly for cryptographic operations inside the TEE/SE

If an attacker gains root access or dumps the device’s memory, the encryption key is still safe because it physically cannot be extracted.


3. Why It’s Important

Without hardware-backed encryption:

  • Keys are stored in software, protected only by file system permissions

  • A rooted device or sophisticated malware can steal them

With hardware-backed encryption:

  • Keys are tied to the device hardware

  • Even if your app's data is exfiltrated, the attacker cannot decrypt it without the physical device and access credentials

  • Optionally, you can require user authentication (PIN, password, or biometric) before the key can be used


Real-World Scenarios

  • Banking apps protecting stored session tokens

  • Healthcare apps storing patient records offline

  • Messaging apps protecting encryption keys for end-to-end chats

  • IoT control apps where device commands must be authenticated


4. How to Use Hardware-Backed Keystore in Android

Here’s how to generate and use a hardware-backed AES key in Android:

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import android.util.Base64

private const val KEY_ALIAS = "my_hardware_backed_key"
private const val TRANSFORMATION = "AES/GCM/NoPadding"

fun getOrCreateHardwareKey(): SecretKey {
    val keyStore = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }

    val existingKey = keyStore.getKey(KEY_ALIAS, null) as? SecretKey
    if (existingKey != null) return existingKey

    val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
    val spec = KeyGenParameterSpec.Builder(
        KEY_ALIAS,
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .setKeySize(256)
        .setIsStrongBoxBacked(true) // Use StrongBox if available
        .build()

    keyGenerator.init(spec)
    return keyGenerator.generateKey()
}

fun encryptData(secretKey: SecretKey, plainText: String): String {
    val cipher = Cipher.getInstance(TRANSFORMATION)
    cipher.init(Cipher.ENCRYPT_MODE, secretKey)
    val iv = cipher.iv
    val encryptedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
    val combined = iv + encryptedBytes
    return Base64.encodeToString(combined, Base64.NO_WRAP)
}

fun decryptData(secretKey: SecretKey, encryptedBase64: String): String {
    val decoded = Base64.decode(encryptedBase64, Base64.NO_WRAP)
    val iv = decoded.copyOfRange(0, 12)
    val cipherData = decoded.copyOfRange(12, decoded.size)
    val cipher = Cipher.getInstance(TRANSFORMATION)
    cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, iv))
    return String(cipher.doFinal(cipherData), Charsets.UTF_8)
}

5. How This Works

  1. Key generation:

    • setIsStrongBoxBacked(true) attempts to store the key in the StrongBox secure hardware if the device supports it (Pixel devices, some Samsung models).

    • If StrongBox isn’t available, it falls back to TEE-backed storage.

  2. Key usage:

    • The AES key never leaves secure hardware.

    • Encryption/decryption operations happen inside the hardware security module.

  3. IV handling:

    • We prepend the IV to the ciphertext so it’s available during decryption.

    • The IV is not secret, but must be unique for each encryption.


6. Checking Hardware Support

You can verify if the key is hardware-backed:

val keyStore = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val key = keyStore.getKey(KEY_ALIAS, null)
val cert = keyStore.getCertificate(KEY_ALIAS)
val isHardwareBacked = cert.publicKey?.format == "X.509" // Basic check

println("Hardware-backed: $isHardwareBacked")

For detailed attestation, use KeyInfo from KeyFactory.getKeySpec(...) to check isInsideSecureHardware.


7. Best Practices

  • Always use hardware-backed keys for sensitive data if the device supports it.

  • Fail gracefully — if hardware-backed storage is unavailable, fall back to software-keystore encryption but with user notification or reduced functionality.

  • Use setUserAuthenticationRequired(true) for extra protection so that the user must authenticate before the key can be used.

  • Rotate keys periodically and securely delete old keys.

  • Never log plaintext or keys.


8. My thoughts

Using Android’s hardware-backed keystore isn’t just a “nice to have” — it’s a necessity for any app that deals with sensitive user data.

By ensuring your keys never leave secure hardware, you protect against a whole class of attacks that target extracted or leaked keys. For banking, fintech, healthcare, and enterprise apps, this can be the difference between a minor breach and a catastrophic data leak.


πŸ’‘ Next step: In a future article, I’ll show how to combine hardware-backed keys with Jetpack Encrypted DataStore so your stored data remains encrypted even if your app’s data directory is compromised.


πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»

Version Control, Branch Management, and CI/CD in Android Development

In Android app development, effective version control, branch management, and CI/CD integration are vital for maintaining code quality, collaboration, and seamless releases. This blog article will provide a comprehensive guide for Android developers to efficiently manage their Android projects, from versioning to deployment.


1. Versioning Your Android App

Versioning your Android app is crucial to track changes over time, manage releases, and ensure backward compatibility. Android uses the versionCode and versionName in build.gradle to define the version of the app.

What is Versioning?

  • versionCode: A unique integer that represents the version of your app internally. It must be incremented with every release (new features, fixes, etc.).

  • versionName: A user-readable string that identifies the version of the app (e.g., 2.3.0, 1.0.1).

Example in build.gradle (Kotlin DSL)

android {
    defaultConfig {
        applicationId = "com.example.app"
        versionCode = 10 // Increment on every release
        versionName = "2.3.0" // User-visible version
    }
}

Versioning Best Practices:

  • Use Semantic Versioning: Follow the format MAJOR.MINOR.PATCH (e.g., 2.3.0).

  • Tag Versions in Git: Create a Git tag for every release (e.g., v2.3.0), so it's easy to track versions and rollback if needed.

git tag v2.3.0
git push origin v2.3.0

2. Product Flavors and Build Variants

In Android, product flavors allow you to build different versions of your app, tailored to various configurations, environments, or users. You can have distinct versions of your app for development, testing, production, or different customers (white-labeling).

Why Use Product Flavors?

  • Multiple configurations: Set different API endpoints, themes, or features for each flavor.

  • Easy differentiation: Build variants like devDebug, qaRelease, prodRelease, etc., to test and deploy different versions of the app.

Configuring Product Flavors in build.gradle

android {
    flavorDimensions += "environment"
    productFlavors {
        create("dev") {
            dimension = "environment"
            applicationIdSuffix = ".dev"
            versionNameSuffix = "-dev"
            buildConfigField("String", "BASE_URL", "\"https://dev.api.example.com\"")
        }
        create("qa") {
            dimension = "environment"
            applicationIdSuffix = ".qa"
            versionNameSuffix = "-qa"
            buildConfigField("String", "BASE_URL", "\"https://qa.api.example.com\"")
        }
        create("prod") {
            dimension = "environment"
            buildConfigField("String", "BASE_URL", "\"https://api.example.com\"")
        }
    }
}

Types of Build Variants:

  • devDebug: For local development with debug configurations.

  • qaRelease: For testing with QA configurations.

  • prodRelease: For the final production version of the app.

You can choose and build the desired flavor directly in Android Studio’s Build Variants panel.

3. Branch Management Strategy

Branch management is a fundamental practice for maintaining a clean and efficient codebase. It allows multiple developers to work simultaneously without overwriting each other’s changes.

Recommended Branching Model

Git Flow

The Git Flow model is widely used for managing feature development, releases, and hotfixes:

main        ← production-ready code
develop     ← current development version
feature/*   ← new features (e.g., feature/login-screen)
bugfix/*    ← minor fixes
hotfix/*    ← urgent production issues
release/*   ← staging for QA and final release
  • main branch: Always contains production-ready code.

  • develop branch: The latest development code that is stable.

  • feature/* branches: Used for individual features or tasks.

  • release/* branches: A preparation for final testing and deployment.

  • hotfix/* branches: Used for critical issues in production.

Best Practices for Branch Management:

  • Start with develop: Create a feature branch from develop for new features.

git checkout develop
git checkout -b feature/onboarding-screen
  • Commit frequently: Make small, incremental commits with meaningful messages.

  • Pull regularly: Keep your branch up-to-date with develop by pulling changes frequently.

git fetch origin
git rebase origin/develop
  • Merge via PRs: Always merge feature branches through a Pull Request (PR) to ensure code quality via code reviews.

Git Commands for Branch Management:

# Create a feature branch
git checkout -b feature/login-screen

# Commit changes
git add .
git commit -m "Added login screen UI"

# Push changes
git push origin feature/login-screen

# Merge into develop
git checkout develop
git merge feature/login-screen

4. CI/CD and Git Integration

Continuous Integration (CI) and Continuous Deployment (CD) are crucial for automating the build, test, and deployment process. In Android development, CI/CD ensures that your app is always in a deployable state and that tests are run consistently.

CI/CD Workflow with GitHub Actions

A simple GitHub Actions workflow might look like this:

name: Android CI

on:
  push:
    branches: [ develop, release/* ]
  pull_request:
    branches: [ develop ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Build devDebug
        run: ./gradlew assembleDevDebug

      - name: Run tests
        run: ./gradlew testDevDebugUnitTest

CI/CD Best Practices:

  • Automate Builds: Trigger builds automatically on PRs and pushes to develop or release/*.

  • Run Unit/UI Tests: Ensure every commit and PR runs unit tests (JUnit, Espresso) and static code checks (ktlint, detekt).

  • Build APK/AAB: Use assembleRelease or bundleRelease to build your app for distribution.


5. Release & Distribution

Once your app is ready for production, it's time to distribute it to users. You can either publish it to the Google Play Store or distribute it to a group of testers via Firebase App Distribution.

Release to Google Play Store:

  • Signing: Use a signed APK or AAB.

  • Versioning: Ensure versionCode is incremented.

  • Use Fastlane for Automation:

    • Automate screenshots, changelogs, and upload to the Play Store.

fastlane android beta

Firebase App Distribution:

Firebase App Distribution is great for distributing pre-release versions to testers.

firebase appdistribution:distribute app-release.apk \
  --app <APP_ID> \
  --groups "qa-team"

Challenges and Solutions
  • Challenge: Managing multiple build variants (e.g., for different countries or environments). Solution: Use Gradle flavors (e.g., country1Debug, country2Release) and automate variant selection in the pipeline.
  • Challenge: Slow build times. Solution: Cache dependencies and parallelize test execution.
  • Challenge: App store approval delays. Solution: Use continuous delivery to deploy to beta channels for early feedback, reserving continuous deployment for internal environments.

My thoughts

Managing versioning, flavors, branches, and CI/CD in Android development is crucial to ensure smooth, scalable, and maintainable app development. By adopting a structured approach to versioning, branching, and automation, you can streamline development, reduce errors, and improve the release process.

With the strategies outlined above, your Android development workflow will be efficient, collaborative, and optimized for both feature development and rapid deployment.


πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»

Detekt : static code analysis tool for Kotlin

Detekt is a static code analysis tool for Kotlin that inspects your Android/Kotlin codebase for:

  •  Code smells
  •  Performance issues
  •  Potential memory/context leaks
  •  Style violations
  • Complexity, nesting, duplication, and more

Think of it as Kotlin’s equivalent of Lint or SonarQube, but tailored for Kotlin best practices — and very extendable.



 Why Use Detekt in Android App Development?

Benefit Description
 Catch bugs early Find context leaks, unclosed resources, and anti-patterns
 Maintain clean code Auto-check complexity, style, naming
CI-ready Works in GitHub Actions, GitLab, Bitrise, etc.
Customizable Add or disable rules, write your own
Kotlin-first No Java compatibility hacks needed

 Step-by-Step Integration in Android Project

 Step 1: Apply Detekt Plugin

In your root build.gradle.kts or build.gradle:

plugins {
    id("io.gitlab.arturbosch.detekt") version "1.23.6"
}

Or use classpath in older plugin DSLs.

 Step 2: Add Configuration (Optional)

Generate a default configuration file:

./gradlew detektGenerateConfig

This creates:
config/detekt/detekt.yml
You can customize rules, thresholds, and disabled checks here.

 Step 3: Run Detekt

Use:

./gradlew detekt

Optional with config:

./gradlew detekt --config config/detekt/detekt.yml

It will output findings in the console and optionally HTML/MD/XML reports.


 Useful Detekt Rules (Leak & Jetpack Compose Focused)

1. UseApplicationContext

Flags dangerous use of Activity context that may lead to leaks.

class Repo(val context: Context) // 🚫 BAD

 Use context.applicationContext.


2. TooManyFunctions, LargeClass, LongMethod

Compose-friendly apps with many lambdas often need these enabled to track complexity.


3. UnsafeCallOnNullableType, CastToNullableType

Helps avoid unexpected crashes from nulls or unsafe casts.


4. ComplexCondition, NestedBlockDepth

Useful for detecting overly nested logic in state-handling Compose UIs.


5. MagicNumber, ForbiddenComment, MaxLineLength

Maintain clean and readable Compose files.


 Optional: Use Detekt with Compose + Custom Rules

You can even write custom Detekt rules to:

  • Detect misuse of remember or LaunchedEffect

  • Prevent ViewModel holding reference to Context

  • Flag mutable state misuse like:

var state by mutableStateOf(...) // without snapshot awareness? πŸ”₯ Flag it

You’d implement these with Detekt's Rule API.


 CI Integration Example (GitHub Actions)

- name: Run Detekt
  run: ./gradlew detekt

You can configure it to fail PRs on violation, ensuring team-wide quality.


 Best Practices When Using Detekt

Practice Benefit
 Customize detekt.yml Tailor rules to team/style guidelines
 Run in CI Prevent regression and enforce code health
 Use for Context Leak Rules Prevent common Android lifecycle bugs
 Track complexity & function size Useful for Compose UI layers
 Extend for custom Compose rules Detect architecture violations

 Output Formats

  • Console (default)

  • HTMLbuild/reports/detekt/detekt.html

  • XML – for tools like SonarQube

  • SARIF – for GitHub Code Scanning


 Summary

Feature Value
Kotlin-first Yes 
Jetpack Compose friendly  Can flag misuse and complexity
Leak prevention  Context, state misuse, null safety
Configurable Fully customizable rules
Extendable Supports custom rule sets
CI Ready Easy to integrate in pipelines

πŸ”— Useful Links


πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»

Shipping High-Quality Android Code with Testing in Iterative Cycles (Kotlin + Jetpack Compose)

In modern Android development, building robust apps goes far beyond just writing code. It’s about delivering high-quality features in iterative cycles with the confidence that everything works as expected. This article walks you through a full feature development cycle in Android using Kotlin, Jetpack Compose, and modern testing practices.

We’ll build a simple feature: a button that fetches and displays user data. You'll learn how to:

  • Structure code using ViewModel and Repository

  • Write unit and UI tests

  • Integrate automated testing in CI/CD pipelines

Let’s dive in 

Full Cycle with Testing - Detailed Steps


Step 1: Set up your environment and dependencies

Before starting, ensure your project has the following dependencies in build.gradle (Module-level):

dependencies {
    implementation "androidx.compose.ui:ui:1.3.0"
    implementation "androidx.compose.material3:material3:1.0.0"
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
    testImplementation "junit:junit:4.13.2"
    testImplementation "org.mockito:mockito-core:4.0.0"
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.3.0"
    androidTestImplementation "androidx.test.ext:junit:1.1.5"
    androidTestImplementation "androidx.test.espresso:espresso-core:3.5.0"
}

Step 2: Implement the Feature

Let’s assume the feature involves a button that, when clicked, fetches user data from an API.

2.1: Create the User Data Model

data class User(val id: Int, val name: String)

2.2: Create the UserRepository to Fetch Data

class UserRepository {
    // Simulate network request with delay
    suspend fun fetchUserData(): User {
        delay(1000)  // Simulating network delay
        return User(id = 1, name = "John Doe")
    }
}

2.3: Create the UserViewModel to Expose Data

class UserViewModel(private val repository: UserRepository) : ViewModel() {
    private val _userData = MutableLiveData<User?>()
    val userData: LiveData<User?> get() = _userData

    fun fetchUser() {
        viewModelScope.launch {
            _userData.value = repository.fetchUserData()
        }
    }
}

2.4: Create the UserScreen Composable to Display Data

@Composable
fun UserScreen(viewModel: UserViewModel) {
    val user by viewModel.userData.observeAsState()
    Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
        Button(onClick = { viewModel.fetchUser() }) {
            Text("Fetch User")
        }
        user?.let {
            Text(text = "User: ${it.name}")
        } ?: Text(text = "No user fetched yet")
    }
}

Step 3: Write Unit Tests

3.1: Unit Test for UserRepository

Write a simple unit test to test that UserRepository correctly fetches user data.

@RunWith(MockitoJUnitRunner::class)
class UserRepositoryTest {
    private lateinit var repository: UserRepository

    @Before
    fun setup() {
        repository = UserRepository()
    }

    @Test
    fun testFetchUserData() = runBlocking {
        val result = repository.fetchUserData()
        assertEquals(1, result.id)
        assertEquals("John Doe", result.name)
    }
}

3.2: Unit Test for UserViewModel

Next, test that the UserViewModel correctly interacts with the repository and updates the UI state.

class UserViewModelTest {
    private lateinit var viewModel: UserViewModel
    private lateinit var repository: UserRepository

    @Before
    fun setup() {
        repository = mock(UserRepository::class.java)
        viewModel = UserViewModel(repository)
    }

    @Test
    fun testFetchUser() = runBlocking {
        val user = User(1, "John Doe")
        `when`(repository.fetchUserData()).thenReturn(user)

        viewModel.fetchUser()

        val value = viewModel.userData.getOrAwaitValue()  // Use LiveData testing extensions
        assertEquals(user, value)
    }
}

Step 4: Write UI Tests with Jetpack Compose

4.1: Write Compose UI Test

Use ComposeTestRule to test the UserScreen composable.

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testFetchUserButton_click() {
    val viewModel = UserViewModel(UserRepository())

    composeTestRule.setContent {
        UserScreen(viewModel)
    }

    // Assert that the UI initially shows the "No user fetched yet" text
    composeTestRule.onNodeWithText("No user fetched yet").assertIsDisplayed()

    // Simulate button click
    composeTestRule.onNodeWithText("Fetch User").performClick()

    // After clicking, assert that "User: John Doe" is displayed
    composeTestRule.onNodeWithText("User: John Doe").assertIsDisplayed()
}

4.2: Write Espresso Test for Hybrid UI (if needed)

In case you’re testing a hybrid UI (Jetpack Compose + XML Views), you can also use Espresso.

@Test
fun testFetchUserButton_click() {
    onView(withId(R.id.fetchUserButton)).perform(click())
    onView(withId(R.id.resultText)).check(matches(withText("User: John Doe")))
}

Step 5: Run All Tests in CI/CD

  • Set up GitHub Actions or Jenkins to automatically run the tests on every push to the repository.

Here’s an example configuration for GitHub Actions:

name: Android CI

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set up JDK
        uses: actions/setup-java@v2
        with:
          java-version: '11'

      - name: Build and test
        run: ./gradlew build testDebugUnitTest connectedDebugAndroidTest

Step 6: Code Review and Refactor

With your code and tests in place:

  •  Refactor for better architecture

  •  Add new states like loading and error

  •  Add tests for edge cases

  •  Merge changes with confidence

  •  Release to staging/production


Step 7: Deploy to Staging or Production

After successful tests, deploy the feature to a staging environment or directly to production, depending on your CI/CD pipeline.


Key Takeaways:

  1. Iterative development: Implement small, testable features and enhance them with every iteration.

  2. Automate testing: Unit tests, UI tests, and integration tests should be automated and run on every code change.

  3. Focus on user behavior: Write tests that mimic real user behavior (e.g., pressing buttons and verifying UI changes).

  4. Continuous integration: Set up CI/CD pipelines to run tests automatically and ensure the stability of your app at all times.

By following this cycle, you ensure that the app is always in a deployable state, with tests proving that each feature works as expected.

Final Thoughts

Iterative development backed by strong testing practices ensures you can deliver robust Android features without fear of breaking the app. With tools like Jetpack Compose, JUnit, and CI/CD, it’s never been easier to ship confidently.

πŸ’‘ “The best code is not only written, it's verified and trusted by tests.”



πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! 😊 please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»✨

How to Fix Android App Bugs Without Breaking Everything Else

When fixing bugs in an Android app project, it's crucial to not just patch the issue, but to ensure stability, performance, and maintainability of the app. Here's a structured list of key areas to address during bug fixing:


 1. Reproduce and Isolate the Bug

  • Reproduce the issue consistently in a local or QA environment.

  • Logcat analysis: Use Log.d, Log.e, Timber, etc., to check stack traces and contextual logs.

  • Crash reports: Utilize tools like Firebase Crashlytics, Sentry, or Bugsnag to identify the root cause.

  • Debug tools: Use Android Studio debugger, breakpoints, or Flipper for runtime inspection.


 2. Identify Root Cause (Not Just Symptoms)

  • Avoid surface-level fixes that mask the issue.

  • Trace through stack traces, thread state, and lifecycle behavior.

  • Use techniques like binary search debugging, rollback comparison, or git bisect.


 3. Check Affected Scope and Dependencies

  • Understand the scope: What modules/features are impacted?

  • Review dependencies (third-party libraries, APIs, database, sensors, etc.).

  • Check for side effects or hidden regressions in related components.


4. Fix Using Best Practices

  • Apply Kotlin null safety, immutability, lifecycle awareness, and coroutine exception handling.

  • Respect architecture (e.g., Clean Architecture, MVVM).

  • Avoid memory leaks (e.g., by cleaning up observers or references in onCleared() or onDestroy()).


5. Test the Fix Thoroughly

  • Unit test the logic.

  • UI/Instrumentation tests (e.g., using Espresso).

  • Edge cases: test on different devices, orientations, languages, and API levels.

  • Use mock data and real-world scenarios.

  • Automate via CI/CD if possible.


6. Code Review & PR Standards

  • Push changes to a feature or hotfix branch.

  • Follow team PR templates/checklists.

  • Include before/after behavior screenshots or videos (especially for UI bugs).

  • Peer review for readability, performance, and maintainability.


7. Update Logs, Metrics, and Documentation

  • Update CHANGELOG.md or release notes.

  • Add JIRA/Trello references and resolution notes.

  • If it's a backend-related issue, sync with backend devs.

  • If API contract changes, update API schema or versioning.


8. Deploy Carefully

  • Use staged rollout (e.g., Play Console’s percentage rollout).

  • Monitor logs and crash analytics post-deploy.

  • Add feature flags if needed for emergency disable.

9. Retrospective or Root Cause Analysis (RCA)

  • Log the root cause and learnings in a shared document.

  • Improve test coverage or monitoring based on the gap.

  • Share insights with the team in sprint retro or knowledge base.


Tools That Help:

Category Tool
Logging Timber, Logcat, Crashlytics
Debugging Android Studio, Flipper, LeakCanary
Monitoring Firebase Performance, Sentry
Testing JUnit, Espresso, MockK, Robolectric
CI/CD GitHub Actions, Bitrise, Jenkins
Issue Tracking Jira, Trello, Linear

Here’s a realistic example of a bug fix in an Android app, including the bug description, root cause, fix implementation, and testing strategy.


Example : App Crashes on Orientation Change While Loading Data


Bug Report

  • Title: App crashes when rotating screen during data loading on ProfileFragment.

  • Severity: High (crashes app)

  • Environment: Android 12, Pixel 5, API 31

  • Steps to Reproduce:

    1. Open the app and navigate to Profile tab.

    2. While the data is loading (loading spinner showing), rotate the screen.

    3. App crashes with IllegalStateException.


Crash Log

java.lang.IllegalStateException: ViewModel not attached to lifecycle yet
    at ProfileViewModel.getProfileData(ProfileViewModel.kt:45)

Root Cause

  • The ProfileFragment was trying to observe LiveData before the view was fully recreated after orientation change.

  • The data load was tied to the fragment’s onCreateView rather than viewLifecycleOwner.


Fix

Refactor LiveData observer to bind with viewLifecycleOwner instead of the fragment's lifecycle.

// Before (incorrect)
viewModel.profileLiveData.observe(this, Observer {
    // update UI
})

// After (correct)
viewModel.profileLiveData.observe(viewLifecycleOwner, Observer {
    // update UI
})

Additionally, use repeatOnLifecycle for Kotlin Flow in Compose or ViewModel coroutine collection:

lifecycleScope.launch {
    viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.profileFlow.collect {
            // Update UI
        }
    }
}

Testing Done

  • Rotated device multiple times during and after data load.

  • Verified no crashes on API 30, 31, 34.

  • Verified ViewModel data not lost.

  • Espresso UI test added for rotation.


PR Notes

πŸ“Œ Fixed crash on orientation change in ProfileFragment by using viewLifecycleOwner for LiveData observer. Added test for configuration change handling.
JIRA Ticket: APP-2931
Impact: Profile screen lifecycle handling.


Improvement

Added lifecycle-aware logging to catch future orientation-related issues:

Log.d("Lifecycle", "ProfileFragment state: ${lifecycle.currentState}")

πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! 😊 please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»✨

Implementing an LRU Cache in Android using Kotlin and Jetpack Compose

In Android development, caching is a critical technique for storing data locally for quicker access, reducing network calls, and enhancing the user experience. One popular caching technique is the Least Recently Used (LRU) cache, which automatically evicts the least recently accessed data when the cache reaches its limit. This post explores how to implement an LRU cache in Android using Kotlin and Jetpack Compose.


What is an LRU Cache?

Least Recently Used (LRU) is a caching strategy that removes the least recently accessed items first when the cache exceeds its defined limit. It's optimal when you want to cache a limited amount of data, such as images, objects, or API responses, and ensure only frequently used items remain in memory.

Benefits:

  • Reduces memory footprint

  • Speeds up data retrieval

  • Avoids unnecessary API calls or file reads

  • Automatically manages cache eviction


Use Cases of LRU Cache in Android

Use Case Description
Image Caching Store bitmap images fetched from the network to avoid repeated downloads
Network Responses Save HTTP responses (e.g., user profile, dashboard data) to speed up load times
File or Object Caching Cache local files or data models in memory for quick reuse
Custom In-App Caching Temporary store for autocomplete suggestions, search history, etc.

 Implementing LRU Cache in Kotlin (Without External Libraries)

Step 1: Create an LRU Cache Manager

class LruCacheManager<K, V>(maxSize: Int) {
    private val cache = object : LruCache<K, V>(maxSize) {
        override fun sizeOf(key: K, value: V): Int {
            return 1 // Customize this if needed (e.g., for Bitmaps)
        }
    }

    fun put(key: K, value: V) {
        cache.put(key, value)
    }

    fun get(key: K): V? {
        return cache.get(key)
    }

    fun evictAll() {
        cache.evictAll()
    }
}

☝️ Note: This uses Android’s built-in LruCache<K, V> from android.util.LruCache.


Example: Caching User Profiles

Let’s say you’re loading user profiles from an API, and want to cache them in memory to avoid reloading them repeatedly.

Step 2: Define a simple data model

data class UserProfile(val id: Int, val name: String, val avatarUrl: String)

Step 3: Create a ViewModel using StateFlow and LruCache

class UserProfileViewModel : ViewModel() {
    private val cache = LruCacheManager<Int, UserProfile>(maxSize = 5)

    private val _userProfile = MutableStateFlow<UserProfile?>(null)
    val userProfile: StateFlow<UserProfile?> = _userProfile

    fun loadUserProfile(userId: Int) {
        cache.get(userId)?.let {
            _userProfile.value = it
            return
        }

        viewModelScope.launch {
            // Simulate network call
            delay(1000)
            val user = UserProfile(userId, "User $userId", "https://picsum.photos/200/200?random=$userId")
            cache.put(userId, user)
            _userProfile.value = user
        }
    }
}

Jetpack Compose UI

@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel = viewModel()) {
    val profile by viewModel.userProfile.collectAsState()

    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(onClick = { viewModel.loadUserProfile((1..10).random()) }) {
            Text("Load Random User")
        }

        Spacer(modifier = Modifier.height(20.dp))

        profile?.let {
            Text(text = it.name, style = MaterialTheme.typography.titleLarge)
            AsyncImage(
                model = it.avatarUrl,
                contentDescription = "Avatar",
                modifier = Modifier.size(120.dp).clip(CircleShape)
            )
        } ?: Text("No user loaded")
    }
}

Best Practices for Using LRU Cache

Tip Description
Use LruCache only for in-memory caching Do not use it for persistent or disk-based caching
Define a sensible maxSize Based on app usage pattern and available memory
For image caching, use Bitmap size as the unit Override sizeOf() method
Evict cache on memory warnings Use onTrimMemory() or onLowMemory() callbacks
Use libraries like Coil, Glide, or Picasso They offer built-in LRU support for image loading

πŸ”š Remember

The LRU caching mechanism is a simple yet powerful technique in Android development. It keeps your UI snappy and responsive by reducing network usage and data load time. While libraries like Glide manage LRU for images out of the box, creating your custom LRU cache gives you flexibility and control—especially when caching JSON responses or domain objects.

Pro Tip: Combine LruCache with StateFlow and Jetpack Compose for real-time UI updates and smoother UX.



#AndroidDevelopment #Kotlin #JetpackCompose #LRUCache #CachingInAndroid #StateFlow #PerformanceOptimization



πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! 😊 please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»✨