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

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! 💻

Jetpack Security APIs – A Complete Guide for Android Engineers

Data security in mobile applications is no longer optional—it’s mandatory. Whether you’re storing sensitive user credentials, tokens, or confidential files, Android provides a modern, developer-friendly way to handle this securely using Jetpack Security APIs. This article walks you through how it works, when to use it, and how to implement it in real-world apps.


Why Use Jetpack Security?

In the past, Android developers relied on manual implementations of encryption, or worse—stored data in plaintext. Jetpack Security solves this by offering:

File encryption using AES256-GCM
Encrypted SharedPreferences with key management
Automatic integration with Android Keystore
Simple, consistent APIs for modern Android (API 23+)


Key Jetpack Security Components

1. MasterKey

At the core of Jetpack Security is the MasterKey, which wraps and manages encryption keys stored in the Android Keystore.

val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()
- MasterKey is automatically stored securely inside the Keystore and used to encrypt/decrypt local data.

2. Encrypted SharedPreferences

Secure key-value storage is essential for user data, tokens, or app config.

val encryptedPrefs = EncryptedSharedPreferences.create(
    context,
    "secure_prefs",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

encryptedPrefs.edit()
    .putString("auth_token", "xyz123")
    .apply()

val token = encryptedPrefs.getString("auth_token", null)

- AES256-SIV ensures key names can’t be inferred by attackers, while AES256-GCM ensures value integrity and confidentiality.


3. EncryptedFile

Need to store entire documents, JSON files, or binary blobs securely? Use EncryptedFile.

val file = File(context.filesDir, "secure_data.txt")

val encryptedFile = EncryptedFile.Builder(
    file,
    context,
    masterKey,
    EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()

// Write encrypted
encryptedFile.openFileOutput().use {
    it.write("Sensitive info".toByteArray())
}

// Read decrypted
val decrypted = encryptedFile.openFileInput().use {
    it.readBytes().decodeToString()
}

The actual file contents are unreadable without the key, even if extracted from a rooted device.


Setup Jetpack Security in your project

Add to your build.gradle:

dependencies {
    implementation "androidx.security:security-crypto:1.1.0-alpha06" // latest as of 2025
}

Security Best Practices

Practice Why it matters
Use MasterKey with AES256_GCM Ensures strong encryption
Store sensitive keys in EncryptedSharedPreferences Avoids plaintext tokens
Never store secrets in BuildConfig or local files Can be reverse-engineered
Use per-user files or keys Prevents data leakage across user accounts
Use biometric auth with strongbox (if available) Adds hardware-backed protection

Bonus: Combine with BiometricPrompt

Use BiometricPrompt to gate access to secure data:

val biometricPrompt = BiometricPrompt(...)
biometricPrompt.authenticate(promptInfo)

On success, you unlock access to keys or read from EncryptedFile.


Real-World Use Cases

  • Store API tokens and refresh tokens

  • Encrypt documents or offline cache

  • Secure authentication credentials

  • Protect local chat or message logs


Limitations and Considerations

  • Not backward-compatible below API 23

  • Keys are bound to the device; uninstalling the app removes them

  • EncryptedFile throws IOException if you try to write over an existing encrypted file—delete first or create new files


Final Thoughts

Jetpack Security makes encryption simple, powerful, and developer-friendly. If you're building a fintech, healthcare, or any privacy-sensitive Android app, adopting it is a no-brainer.

As Android continues to strengthen platform security, these APIs offer a future-proof path to protecting user trust—and your app’s reputation.


 My Thoughts as a Senior Android Engineer

Over the years, I’ve seen the damage caused by insecure data storage—especially in financial and enterprise apps. Jetpack Security is one of the best Android Jetpack libraries to arrive in recent years because:

  • It removes the guesswork from encryption

  • It integrates seamlessly with existing architecture

  • It aligns perfectly with Clean Architecture and MVVM

Pro tip: Abstract EncryptedPrefs and EncryptedFile inside a secure repository for easier testing and separation of concerns.



📢 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! 💻

Integration with Android Launcher Functionality

Supercharge your Android apps—SMS, Weather, or News—by tightly coupling them with launcher experiences.

The Android launcher is the user's gateway to their device experience. As Android engineers, we often focus on building standalone apps, but deeper integration with the launcher opens up new levels of usability, context-awareness, and engagement.

In this article, we’ll explore real-world use cases like building SMS, News, and Weather apps with launcher-based integration, architecture best practices, and sample implementations that enhance the home screen experience.


Why Integrate with the Android Launcher?

Launcher integrations provide:

  • At-a-glance content via widgets or glanceable views

  • Seamless background sync (e.g., weather updates, unread messages)

  • Custom actions or deep links directly from the launcher

  • Push notifications + dynamic badges

This leads to increased user engagement, faster access, and smarter contextual updates.


Use Case 1: Weather App Integration

Features

  • Dynamic widget showing weather conditions

  • Auto-updated forecast every few hours

  • Tap widget to open detailed weather view

 Implementation Steps

1. Add App Widget to Launcher

class WeatherWidgetProvider : AppWidgetProvider() {
    override fun onUpdate(
        context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray
    ) {
        for (appWidgetId in appWidgetIds) {
            val views = RemoteViews(context.packageName, R.layout.widget_weather)
            views.setTextViewText(R.id.tvTemperature, "74°F | Sunny")
            appWidgetManager.updateAppWidget(appWidgetId, views)
        }
    }
}

2. Enable Periodic Updates

Use WorkManager or AlarmManager to fetch weather periodically using APIs like OpenWeatherMap.

val workRequest = PeriodicWorkRequestBuilder<WeatherSyncWorker>(6, TimeUnit.HOURS).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "WeatherSync", ExistingPeriodicWorkPolicy.KEEP, workRequest
)

3. Tap to Launch App

val pendingIntent = PendingIntent.getActivity(
    context, 0, Intent(context, WeatherActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT
)
views.setOnClickPendingIntent(R.id.weatherWidgetContainer, pendingIntent)

Use Case 2: SMS App with Notification Badge & Shortcut

 Features

  • Launcher icon shows unread count (notification dot)

  • Supports deep link to specific conversations

  • Home screen shortcut to start a new SMS

Implementation Highlights

1. Notification Badge with ShortcutManagerCompat

val shortcut = ShortcutInfoCompat.Builder(context, "new_sms")
    .setShortLabel("New Message")
    .setIcon(IconCompat.createWithResource(context, R.drawable.ic_message))
    .setIntent(Intent(context, ComposeSMSActivity::class.java).apply {
        action = Intent.ACTION_VIEW
    })
    .build()

ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)

2. Unread Count via Notification Dots

Use NotificationManagerCompat to trigger notification-based badge:

val notification = NotificationCompat.Builder(context, "sms_channel")
    .setContentTitle("1 new message")
    .setSmallIcon(R.drawable.ic_sms)
    .setNumber(1) // Badge count
    .build()

NotificationManagerCompat.from(context).notify(101, notification)

Use Case 3: News Feed Panel or Widget on Launcher

Some launchers (e.g., Xiaomi, custom OEMs) allow a left swipe panel with live content like news.

Features

  • Horizontally scrollable news headlines

  • Tap to open article in the app

  • Configurable categories

 Tips for Implementation

  • Use RecyclerView in AppWidget or RemoteViewsFactory for headlines.

  • Fetch data using a headless ViewModel + Retrofit + Coroutines.

  • Enable user to configure news preferences via an app setting screen and persist in SharedPreferences.


 Architecture Tips

Layer Recommendation
UI Jetpack Compose or XML (for widget layouts)
ViewModel StateFlow for reactive UI state
Data Layer Retrofit + Room (cached content)
Background WorkManager for API sync
DI Hilt or Koin
Permissions Runtime + fallback for denied permissions

Best Practices

  • Use minimal updates in widgets to conserve battery

  • Cache data to reduce network calls

  • Modularize SMS, Weather, News as separate features

  • Be mindful of launcher security restrictions for shortcuts and intents

  • Test on stock launchers and OEM launchers (Samsung, MIUI, etc.)


📱 Launcher-Ready App: Sample Folder Structure

com.example.launcherintegration/
├── ui/
│   ├── widget/
│   └── compose/
├── data/
│   ├── model/
│   ├── network/
│   └── repository/
├── features/
│   ├── sms/
│   ├── weather/
│   └── news/
├── background/
│   └── workers/
└── di/

Testing Launcher Integration

  • Use Espresso-Intents to validate deep links from shortcuts

  • Use Robolectric for widget rendering in unit tests

  • Manually test on various OEM launchers (e.g., Pixel, Samsung, OnePlus)


Integrating Android apps with launcher functionality—whether through widgets, shortcuts, or notification badges—creates a richer, more contextual experience for users. As Android engineers, embracing these integrations allows us to deliver proactive, interactive, and intuitive user journeys right from the home screen.


TL;DR

  • Use AppWidgetProvider for glanceable views

  • Leverage ShortcutManagerCompat for dynamic actions

  • Employ WorkManager for reliable background sync

  • Optimize launcher-aware UI for engagement and battery efficiency


📢 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! 💻

Sealed Classes and Data Classes for State Management in Android

 In Android development, managing the state of an application effectively is critical for providing a smooth user experience. Sealed classes and data classes are two Kotlin features that work exceptionally well together to model and manage UI states in a clean and robust way, particularly when using Jetpack Compose or any other modern Android architecture like MVVM.

1. Sealed Classes Overview

A sealed class in Kotlin is a special class that restricts class inheritance to a limited set of types. It can have a fixed set of subclasses, which allows the compiler to know all possible types. This is particularly useful for state management because it enables exhaustive when checks and helps ensure that all possible states are covered.

Sealed classes are typically used to represent different states or outcomes (like success, error, or loading) in the app, such as when handling network responses, UI states, or other processes.

sealed class UIState {
    object Loading : UIState()
    data class Success(val data: String) : UIState()
    data class Error(val message: String) : UIState()
}

2. Data Classes Overview

A data class is a class in Kotlin that is used to hold data. It automatically generates useful methods such as toString(), equals(), hashCode(), and copy(). It's mainly used to represent immutable data, which is ideal for handling states that involve encapsulating information (like success or error data) without altering the state itself.

For example, in the sealed class example above, the Success and Error states are modeled using data classes, allowing them to hold and manage state-specific data.

3. How They Work Together

Sealed classes and data classes work together to encapsulate various states in a clean, type-safe manner. Here's how they work together in state management:

  • Sealed Class for Type Safety: Sealed classes are used to restrict and control the possible states of the system. The compiler knows all subclasses, so if a new state is added, it forces a code update to ensure that all states are handled properly.

  • Data Class for Holding Data: Data classes are used within sealed classes to hold and represent state-specific data, such as the result of an API call or any other data-driven UI state.

4. Use Cases in Android Apps

Here are some practical use cases where sealed classes and data classes are often used together:

Use Case 1: Network Request Handling

Consider a scenario where you need to display the state of a network request (loading, success, or error). You can use a sealed class to represent the possible states and data classes to carry the data in the success and error states.

sealed class UIState {
    object Loading : UIState()
    data class Success(val data: List<User>) : UIState()
    data class Error(val message: String) : UIState()
}

class UserViewModel : ViewModel() {
    private val _uiState = MutableLiveData<UIState>()
    val uiState: LiveData<UIState> get() = _uiState

    fun loadUsers() {
        _uiState.value = UIState.Loading
        viewModelScope.launch {
            try {
                val users = api.getUsers()  // network request
                _uiState.value = UIState.Success(users)
            } catch (e: Exception) {
                _uiState.value = UIState.Error(e.message ?: "An unknown error occurred")
            }
        }
    }
}

In this example:

  • UIState is a sealed class with three possible states: Loading, Success, and Error.

  • Success and Error are data classes used to hold specific data related to each state (list of users for success and an error message for failure).

  • The loadUsers() function simulates a network request and updates the state accordingly.

Use Case 2: Form Validation

Another common use case is managing the state of a form (e.g., checking if input is valid, showing errors, or displaying success).

sealed class ValidationState {
    object Valid : ValidationState()
    data class Invalid(val errorMessage: String) : ValidationState()
}

class FormViewModel : ViewModel() {
    private val _validationState = MutableLiveData<ValidationState>()
    val validationState: LiveData<ValidationState> get() = _validationState

    fun validateInput(input: String) {
        if (input.isNotEmpty() && input.length > 5) {
            _validationState.value = ValidationState.Valid
        } else {
            _validationState.value = ValidationState.Invalid("Input must be at least 6 characters long")
        }
    }
}

In this example:

  • ValidationState is a sealed class with two possible states: Valid and Invalid.

  • Invalid is a data class that holds the error message when the form input is invalid.

Use Case 3: UI State Management with Jetpack Compose

In Jetpack Compose, you can use sealed classes to manage different UI states such as loading, displaying content, or handling errors in a declarative way.

sealed class UIState {
    object Loading : UIState()
    data class Content(val message: String) : UIState()
    data class Error(val error: String) : UIState()
}

@Composable
fun MyScreen(viewModel: MyViewModel) {
    val uiState by viewModel.uiState.observeAsState(UIState.Loading)

    when (uiState) {
        is UIState.Loading -> {
            CircularProgressIndicator()
        }
        is UIState.Content -> {
            Text((uiState as UIState.Content).message)
        }
        is UIState.Error -> {
            Text("Error: ${(uiState as UIState.Error).error}")
        }
    }
}

In this case:

  • UIState is a sealed class used to handle different states in the UI.

  • The Content and Error data classes hold the actual data that is rendered in the UI.

  • Jetpack Compose will update the UI reactively based on the current state.

5. Benefits of Using Sealed and Data Classes Together

  • Exhaustiveness Checking: With sealed classes, the compiler ensures that you handle every possible state, reducing the chances of unhandled states or bugs.

  • Type Safety: Data classes encapsulate data in a structured way, while sealed classes ensure that the states are known and finite, making the system more predictable and less prone to errors.

  • Easy Debugging and Error Handling: By using data classes to represent different states, especially errors, it's easier to capture and display the exact error message or data related to a specific state.

 Thoughts

Sealed classes and data classes complement each other perfectly for state management in Android development, providing a robust, type-safe, and maintainable way to represent various states. Sealed classes give you control over state variation, while data classes store relevant data for each state. Together, they are an excellent choice for managing UI states, network responses, form validation, and other scenarios where different outcomes need to be handled in a clean and predictable manner.

📢 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! 💻✨

Common Scenarios & Examples of Coroutine Memory Leaks

 What is a Memory Leak?

A memory leak occurs when memory that is no longer needed is not released because the program still holds references to it. In Android, this means the garbage collector (GC) cannot reclaim that memory, potentially leading to OutOfMemoryError and app crashes.


 How Do Memory Leaks Occur in a Coroutine?

Coroutines are lightweight threads, but if not scoped and managed correctly, they can continue running even when the component (like an Activity or ViewModel) is destroyed. This can lead to memory leaks.





 Common Scenarios & Examples of Coroutine Memory Leaks

Here are some common scenarios where memory leaks can happen with coroutines, what causes them, what happens afterward, and how to fix them:


1. Coroutine Scope Tied to a Destroyed Component

❌ Problem:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {
            delay(10_000) // 10-second delay
            Log.d("Coroutine", "Finished!")
        }
    }
}

 What Happens:

Even if the user rotates the screen or navigates away, the coroutine keeps running unless cancelled. If it references this, the Activity is retained in memory.

✅ Fix:

Use a lifecycle-aware scope, like lifecycleScope, and ensure the task is cancellable:

lifecycleScope.launch {
    withTimeoutOrNull(5000) {
        delay(10_000)
    }
}

2. GlobalScope Misuse

❌ Problem:

GlobalScope.launch {
    val bitmap = loadLargeBitmap()
    imageView.setImageBitmap(bitmap)
}

 What Happens:

  • GlobalScope lives for the entire app lifecycle.

  • If imageView belongs to a destroyed Activity, it is retained in memory.

✅ Fix:

Use viewModelScope, lifecycleScope, or a custom CoroutineScope that is cancelled appropriately:

class MyViewModel : ViewModel() {
    fun loadImage() {
        viewModelScope.launch {
            val bitmap = loadLargeBitmap()
            _bitmapLiveData.value = bitmap
        }
    }
}

3. ViewModel or Activity Holds Long-lived Coroutine with UI Reference

❌ Problem:

class MyViewModel : ViewModel() {
    var activity: MainActivity? = null

    fun doWork() {
        viewModelScope.launch {
            delay(10_000)
            activity?.updateUI() // Leaks MainActivity
        }
    }
}

 What Happens:

Even after MainActivity is destroyed, the coroutine keeps a reference to it via activity.

✅ Fix:

Avoid passing UI references. Use LiveData, StateFlow, or a callback interface that is weakly referenced.


4. Job not Cancelled in onDestroy()

❌ Problem:

class MyActivity : AppCompatActivity() {
    private val job = Job()
    private val scope = CoroutineScope(Dispatchers.Main + job)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        scope.launch {
            fetchData()
        }
    }

    //  Missing job.cancel()
}

 What Happens:

The coroutine continues running even after the Activity is destroyed.

✅ Fix:

Cancel the job:

override fun onDestroy() {
    super.onDestroy()
    job.cancel()
}

5. Flow Collection Without Proper Scope

❌ Problem:

viewModel.dataFlow.onEach {
    textView.text = it
}.launchIn(GlobalScope) //  WRONG

 What Happens:

This will outlive the lifecycle of the view that textView belongs to.

✅ Fix:

viewModel.dataFlow
    .onEach { textView.text = it }
    .launchIn(lifecycleScope)

Or use:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.dataFlow.collect {
            textView.text = it
        }
    }
}

 Best Practices to Avoid Coroutine Memory Leaks

 Practice  Explanation
Use viewModelScope, lifecycleScope These scopes are lifecycle-aware and cancel automatically.
Avoid GlobalScope for UI tasks GlobalScope never cancels, leading to leaks.
Always cancel custom scopes Manually call job.cancel() in onDestroy() or similar.
Avoid long-lived references to Context/UI Don’t store Activities/Views inside ViewModels or background coroutines.
Use structured concurrency Always launch coroutines inside a well-defined scope.
Make coroutines cancellable Use withTimeout, isActive, and ensure long-running tasks honor cancellation.
Use repeatOnLifecycle for Flow Ensures collection only happens during active lifecycle state.

 Use Case Example: Fetch User Profile and Update UI

❌ Incorrect (Leaky)

class ProfileActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        GlobalScope.launch {
            val user = fetchUserProfile()
            runOnUiThread {
                profileTextView.text = user.name
            }
        }
    }
}

✅ Correct (Leak-safe)

class ProfileActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            val user = fetchUserProfile()
            profileTextView.text = user.name
        }
    }
}

 Summary

🛑 Don't Do This ✅ Do This Instead
Launch coroutine in GlobalScope Use viewModelScope or lifecycleScope
Hold references to Activity/View Use LiveData/StateFlow for updates
Ignore coroutine cancellation Make coroutine cancellable
Launch coroutine in onCreate w/o guard Use repeatOnLifecycle or cancel scope

📢 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! 💻✨

System Design Interviews in Android

System design interviews can be a daunting part of the interview process for Android Engineers. While the focus often leans towards architecture, performance, scalability, and reliability, many system design concepts are transferable to mobile development, especially when working with Kotlin and Jetpack Compose. In this article, we’ll explore 12 essential algorithms that play a pivotal role in system design, offering insights into how they can be used effectively in Android Kotlin Compose-based applications.


1. Bloom Filter: Reducing Costly Lookups

A simple example of a Bloom Filter to prevent unnecessary database or network lookups.

class BloomFilter(val size: Int, val hashFunctions: List<(String) -> Int>) {
    private val bitSet = BitSet(size)

    fun add(element: String) {
        hashFunctions.forEach {
            val hash = it(element) % size
            bitSet.set(hash)
        }
    }

    fun contains(element: String): Boolean {
        return hashFunctions.all {
            val hash = it(element) % size
            bitSet.get(hash)
        }
    }
}

// Usage Example
val bloomFilter = BloomFilter(
    size = 1000,
    hashFunctions = listOf(
        { input: String -> input.hashCode() },
        { input: String -> input.length.hashCode() }
    )
)

bloomFilter.add("John")
println(bloomFilter.contains("John")) // Should return true
println(bloomFilter.contains("Alice")) // Likely false

2. Geohash: Location-Based Services

Using Geohash for nearby locations.

// Example using Geohash library
import org.geohash.GeoHash
import com.google.android.gms.maps.model.LatLng

fun getNearbyGeohash(latitude: Double, longitude: Double): String {
    val geohash = GeoHash.withCharacterPrecision(latitude, longitude, 7)
    return geohash.toBase32()
}

val geohash = getNearbyGeohash(37.7749, -122.4194) // San Francisco
println("Geohash: $geohash")

3. Hyperloglog: Estimating Unique Elements

This can be implemented by tracking unique user IDs or events in a mobile app.

// Using Hyperloglog for tracking unique views
val uniqueUsers = mutableSetOf<String>()

fun addUniqueUser(userId: String) {
    uniqueUsers.add(userId)
}

fun getUniqueUserCount() = uniqueUsers.size

// Simulate adding users
addUniqueUser("user1")
addUniqueUser("user2")
addUniqueUser("user1")

println("Unique users: ${getUniqueUserCount()}")

4. Consistent Hashing: Efficient Data Distribution

A consistent hashing example to distribute tasks.

class ConsistentHashing(private val nodes: List<String>) {
    fun getNode(key: String): String {
        val hash = key.hashCode()
        val nodeIndex = Math.abs(hash % nodes.size)
        return nodes[nodeIndex]
    }
}

val nodes = listOf("Node A", "Node B", "Node C")
val consistentHash = ConsistentHashing(nodes)

println(consistentHash.getNode("user1"))  // It could print "Node B"

5. Merkle Tree: Verifying Data Integrity

Example of a Merkle Tree used for verifying data integrity.

data class MerkleNode(val hash: String, val left: MerkleNode? = null, val right: MerkleNode? = null)

fun createMerkleTree(data: List<String>): MerkleNode {
    if (data.size == 1) {
        return MerkleNode(data[0])
    }

    val mid = data.size / 2
    val left = createMerkleTree(data.subList(0, mid))
    val right = createMerkleTree(data.subList(mid, data.size))

    val combinedHash = (left.hash + right.hash).hashCode().toString()
    return MerkleNode(combinedHash, left, right)
}

val tree = createMerkleTree(listOf("A", "B", "C", "D"))
println("Root Hash: ${tree.hash}")

6. Raft Algorithm: Consensus in Distributed Databases

A simplified simulation of Raft’s consensus in Android.

// Simulate Raft leader election process
class RaftLeaderElection(val nodes: List<String>) {
    private var leader: String? = null

    fun electLeader(): String {
        leader = nodes.random()
        return leader!!
    }
}

val raft = RaftLeaderElection(listOf("Node A", "Node B", "Node C"))
println("Leader is: ${raft.electLeader()}")

7. Lossy Count: Estimating Item Frequencies

Using the Lossy Count algorithm to estimate frequencies of items.

class LossyCount(val threshold: Int) {
    private val counts = mutableMapOf<String, Int>()

    fun add(element: String) {
        counts[element] = counts.getOrDefault(element, 0) + 1
    }

    fun getFrequencies(): Map<String, Int> {
        return counts.filter { it.value >= threshold }
    }
}

val lossyCount = LossyCount(2)
lossyCount.add("Apple")
lossyCount.add("Apple")
lossyCount.add("Banana")

println(lossyCount.getFrequencies())  // Expected: {Apple=2}

8. QuadTree: Spatial Partitioning

A basic implementation of QuadTree for location-based services.

class QuadTree(val boundary: Rect, val capacity: Int) {
    private val points = mutableListOf<LatLng>()
    private var divided = false

    fun insert(point: LatLng): Boolean {
        if (!boundary.contains(point)) return false
        if (points.size < capacity) {
            points.add(point)
            return true
        }
        if (!divided) {
            subdivide()
        }
        // Insert into the appropriate quadrant
        return true
    }

    private fun subdivide() {
        divided = true
        // Divide into 4 quadrants
    }
}

data class LatLng(val latitude: Double, val longitude: Double)
data class Rect(val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double) {
    fun contains(point: LatLng) = point.latitude in latMin..latMax && point.longitude in lonMin..lonMax
}

val rect = Rect(37.0, 38.0, -122.5, -123.0)
val quadTree = QuadTree(rect, 2)
val point = LatLng(37.7749, -122.4194)

quadTree.insert(point)

9. Operational Transformation: Real-Time Collaboration

Basic collaboration on shared data.

// Simulate real-time text collaboration
class OperationalTransformation {
    var document = StringBuilder()

    fun applyOperation(op: String) {
        document.append(op)
    }

    fun getDocument() = document.toString()
}

val ot = OperationalTransformation()
ot.applyOperation("Hello ")
ot.applyOperation("World!")

println("Document: ${ot.getDocument()}")

10. Leaky Bucket: Rate Limiting in APIs

Simple Leaky Bucket algorithm for controlling API rate limits.

class LeakyBucket(val capacity: Int, val leakRate: Int) {
    private var waterLevel = 0

    fun addRequest() {
        if (waterLevel < capacity) {
            waterLevel++
            println("Request added. Water level: $waterLevel")
        } else {
            println("Bucket full, try again later.")
        }
    }

    fun leak() {
        if (waterLevel > 0) {
            waterLevel -= leakRate
        }
    }
}

val bucket = LeakyBucket(capacity = 5, leakRate = 1)

bucket.addRequest()  // Should succeed
bucket.addRequest()  // Should succeed
bucket.leak()  // Leaks 1 unit

11. Rsync: File Synchronization

Simplified rsync simulation for syncing files.

fun syncFiles(source: String, destination: String) {
    println("Syncing files from $source to $destination")
    // Simulate file sync
}

syncFiles("localFile", "remoteServer")

12. Ray Casting: Collision Detection

A basic example for collision detection in Android.

// Simulate ray casting for collision detection in 2D space
fun isCollision(ray: Line, objectShape: Rect): Boolean {
    return ray.intersects(objectShape)
}

data class Line(val start: Point, val end: Point) {
    fun intersects(rect: Rect): Boolean {
        // Logic to check if the line intersects the rectangle
        return true
    }
}

data class Point(val x: Int, val y: Int)
data class Rect(val x: Int, val y: Int, val width: Int, val height: Int)

val ray = Line(Point(0, 0), Point(5, 5))
val rect = Rect(2, 2, 2, 2)

println(isCollision(ray, rect))  // Will print true if there's a collision

Each of these algorithms can be adapted to Android Kotlin Compose for efficient, scalable applications, enabling you to optimize performance and user experience.

📢 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! 💻✨