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

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! πŸ’»

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! πŸ’»

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! πŸ’»✨

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! πŸ’»✨



Cracking the Senior Android Engineer Interview: What to Expect from Start to Finish

Stepping into the interview process for a Senior Android Engineer role can be both exciting and challenging. Whether you’re preparing for your dream job at a big tech firm or a promising startup, understanding the structure and topics across each interview stage is crucial.

In this blog, we’ll break down the commonly asked topics from the initial recruiter round to the final bar-raiser interview, including everything from Kotlin coding to system design and behavioral assessments.


 Initial HR/Recruiter Screening

This is mostly a soft round to gauge your fit for the role and company culture.

Topics:

  • Summary of your Android development experience

  • Key projects you've worked on (apps, user base, challenges)

  • Current role, notice period, and salary expectations

  • Why you're exploring new opportunities

  • Communication skills and professional demeanor


Online Technical Coding Round

Here the focus is on core problem-solving skills using Kotlin.

Topics to Prepare:

  • Data Structures & Algorithms:

    • Arrays, LinkedLists, Trees, Graphs, HashMaps

    • Sorting, searching, recursion, backtracking

  • Kotlin-specific Concepts:

    • Coroutines (Job, SupervisorJob, Dispatchers)

    • Flow, StateFlow, and Channels

    • Lambda expressions, extension functions, and delegates

  • Concurrency & Asynchronous Programming in Android

Tools: HackerRank, Codility, or take-home assignments


System Design Interview

This round evaluates how you architect scalable, modular Android apps.

Key Focus Areas:

  • Clean Architecture (Presentation → Domain → Data layers)

  • MVVM vs MVI vs MVP — and when to choose which

  • Real-world design:

    • Offline-first apps

    • Syncing with API and caching (Room, DataStore)

    • Push notifications & background sync (WorkManager)

  • Dependency Injection (Hilt/Dagger2)

  • Multi-module project structuring

Example: “Design a banking app with authentication, balance display, and transaction history”


Android Platform & Jetpack Deep Dive

Here, expect questions on Jetpack libraries, Compose UI, and platform internals.

Topics:

  • Jetpack Compose:

    • State management

    • Recomposition and performance pitfalls

  • Lifecycle Management:

    • ViewModel, LifecycleOwner, LifecycleObserver

  • Jetpack Libraries:

    • Navigation, Room, Paging, WorkManager, DataStore

  • Security:

    • Encrypted storage, biometric authentication, Keystore

  • Accessibility:

    • Compose semantics, TalkBack support, content descriptions


Testing & Debugging

A great Senior Android Engineer writes testable and maintainable code.

What You Should Know:

  • Unit Testing with JUnit, Mockito, MockK

  • UI Testing with Espresso and Compose testing APIs

  • Integration Testing with HiltTestApplication

  • Debugging ANRs, memory leaks (LeakCanary), performance bottlenecks

  • Using tools like Crashlytics, Logcat, StrictMode


CI/CD, DevOps, and Release Management

Modern Android teams value automation and fast feedback cycles.

Topics:

  • CI/CD tools: Jenkins, GitHub Actions, Bitrise

  • Gradle optimization, build flavors, product types

  • Feature flag implementation (Gradle + Firebase Remote Config)

  • Code quality enforcement: Detekt, Lint, SonarQube

  • Secure and efficient release strategies (Play Store, Firebase App Distribution)


Behavioral & Leadership Assessment

This round checks for team collaboration, mentorship, and decision-making skills.

Example Questions:

  • Tell us about a time you led a project or mentored a junior

  • How do you resolve disagreements with product or design teams?

  • What’s your strategy for balancing tech debt vs. feature delivery?

  • How do you stay updated with evolving Android trends?

Tip: Use the STAR method (Situation, Task, Action, Result) to answer.


Bar-Raiser or VP/CTO Round

This is the make-or-break round for many companies.

Focus Areas:

  • End-to-end ownership of features and impact

  • Trade-offs made during architecture decisions

  • Innovation, optimization, or cost-saving initiatives you've led

  • Long-term vision, technical leadership, and culture fit


πŸ”š Final Thoughts

Landing a Senior Android Engineer role isn’t just about writing great Kotlin code. It’s about demonstrating architectural mastery, leadership, and a product-first mindset across every round.

Start prepping smart by:

  • Practicing DSA in Kotlin

  • Building or refactoring a multi-module Compose app

  • Designing systems (like a chat app or e-commerce app)

  • Writing testable, clean code

  • Staying up to date with Jetpack and security best practices


What Next?

Want mock interview questions, detailed Kotlin exercises, or a full Android app architecture walkthrough? Drop a comment or subscribe for more deep-dives every week.


πŸ”— Related Reads:



πŸ“’ 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! πŸ’»✨

Exploring Scope Functions in Kotlin Compose

Kotlin provides several powerful scope functions that make your code more readable and concise. These scope functions — let, run, apply, also, and with — are particularly useful when working with Kotlin's modern Android development framework, Jetpack Compose. 

Scope functions in Kotlin allow you to execute a block of code within the context of an object. Each of these functions has different characteristics and return values. They can be used for various purposes, like modifying an object, chaining function calls, or simplifying code readability.

1. let – Transform the Object and Return the Result

The let function executes a block of code on the object it is invoked on and returns the result of the block. It's often used when working with an object and returning a result without modifying the original object.

Syntax:

val result = object.let {
    // Do something with object
    "result"
}

Example in Compose: Suppose we want to handle an event in Jetpack Compose like a button click, where we only need to transform the result or pass it on to another function:

val buttonText = "Click me"
val result = buttonText.let {
    it.toUpperCase()
}

Text(text = result)  // Output: "CLICK ME"

In this example, the let function transforms buttonText to uppercase without modifying the original string.

2. run – Execute a Block and Return the Result

run is similar to let, but it is typically used when you want to execute a block of code and return a result. Unlike let, run doesn’t take the object as an argument — instead, it works within the object's context.

Syntax:

val result = object.run {
    // Do something with object
    "result"
}

Example in Compose: When creating a composable, you can use run to set up complex UI elements or operations:

val result = "Hello".run {
    val length = length
    "Length of text: $length"
}

Text(text = result)  // Output: "Length of text: 5"

Here, we used run to access properties and perform operations on a string. The function directly returns the result without modifying the object.

3. apply – Configure an Object and Return It

The apply function is used when you want to modify an object and return the modified object itself. It’s particularly useful for setting multiple properties on an object.

Syntax:

val modifiedObject = object.apply {
    // Modify object properties
}

Example in Compose: For instance, in Jetpack Compose, you can use apply when configuring a Modifier object to add multiple modifications:

val modifier = Modifier
    .padding(16.dp)
    .apply {
        background(Color.Blue)
        fillMaxSize()
    }

Box(modifier = modifier) {
    Text(text = "Hello World")
}

In this case, the apply function allows chaining multiple properties on the Modifier object and returns the same object after applying changes.

4. also – Perform an Action and Return Object

The also function is often used when you want to perform additional actions on an object but don’t want to change or return a new object. It’s often used for logging or debugging.

Syntax:

val objectWithAction = object.also {
    // Perform actions like logging
}

Example in Compose: Suppose you want to log a value when a user clicks a button in Compose:

val clickCount = remember { mutableStateOf(0) }

Button(onClick = {
    clickCount.value = clickCount.value.also {
        println("Button clicked: ${clickCount.value} times")
    } + 1
}) {
    Text("Click Me")
}

In this example, the also function is used to log the click count before updating the value of clickCount.

5. with – Execute a Block on an Object Without Returning It

The with function is used when you want to perform several actions on an object without modifying or returning it. It operates similarly to run, but unlike run, which operates in the context of the object, with requires the object to be passed explicitly.

Syntax:

with(object) {
    // Perform actions
}

Example in Compose: If you want to configure a composable’s properties, you can use with for better readability:

val modifier = with(Modifier) {
    padding(16.dp)
    background(Color.Green)
    fillMaxSize()
}

Box(modifier = modifier) {
    Text("Welcome to Compose!")
}

Here, with is used to apply multiple modifiers without repeatedly referencing the Modifier object.

Key Differences Between Scope Functions

  • let: Useful when you transform the object and return a result. The object is passed as an argument to the block.

  • run: This is similar to let, but the object is accessed directly within the block. It is helpful when returning a result after performing operations.

  • apply: Modifies the object and returns the object itself. Ideal for object configuration.

  • also: Similar to apply, but used primarily for performing side actions (like logging), while returning the original object.

  • with: Works like run, but requires the object to be passed explicitly and is used when you need to operate on an object without modifying it.

When to Use Each in Jetpack Compose

  • let: When you must transform or pass an object’s value.

  • run: When performing operations within the context of an object and returning the result.

  • apply: When you need to modify an object (like a Modifier) and return it after changes.

  • also: For performing additional actions (e.g., logging or debugging) without changing the object.

  • with: When you want to execute multiple operations on an object without modifying it.

Summary

Scope functions are essential to Kotlin’s functional programming style, offering a concise and readable way to work with objects. In Jetpack Compose, they help streamline UI development, manage states, and enhance overall code readability. Whether you’re configuring UI elements, performing transformations, or logging actions, scope functions can significantly reduce boilerplate and improve the efficiency of your Kotlin Compose code.

πŸ“’ 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! πŸ’»✨

Coin Change Problem in Kotlin: Multiple Approaches with Examples

The coin change problem is a classic leet coding challenge often encountered in technical interviews. The problem asks:

Given an array of coin denominations and a target amount, find the fewest number of coins needed to make up that amount. If it's not possible, return -1. You can use each coin denomination infinitely many times.

Here are multiple ways to solve the Coin Change problem in Kotlin, with detailed explanations and code examples. I'll present two distinct approaches:

  1. Dynamic Programming (Bottom-Up approach)
  2. Recursive Approach with Memoization (Top-Down)

Approach 1: Dynamic Programming (Bottom-Up)

Idea:

  • Build an array dp where each dp[i] indicates the minimum number of coins required for the amount i.
  • Initialize the array with a large number (representing infinity).
  • The base case is dp[0] = 0.

Steps:

  • For each amount from 1 to amount, try every coin denomination.
  • Update dp[i] if using the current coin leads to fewer coins than the current value.

Kotlin Solution:

fun coinChange(coins: IntArray, amount: Int): Int {
    val max = amount + 1
    val dp = IntArray(amount + 1) { max }
    dp[0] = 0

    for (i in 1..amount) {
        for (coin in coins) {
            if (coin <= i) {
                dp[i] = minOf(dp[i], dp[i - coin] + 1)
            }
        }
    }
    
    return if (dp[amount] > amount) -1 else dp[amount]
}

// Usage:
fun main() {
    println(coinChange(intArrayOf(1, 2, 5), 11)) // Output: 3
    println(coinChange(intArrayOf(2), 3))        // Output: -1
    println(coinChange(intArrayOf(1), 0))        // Output: 0
}

Time Complexity: O(amount * coins.length)
Space Complexity: O(amount)


Approach 2: Recursive Approach with Memoization (Top-Down)

Idea:

  • Define a recursive function solve(remainingAmount) that returns the minimum coins required.
  • Use memoization to store previously computed results, avoiding redundant calculations.

Steps:

  • For each call, explore all coin denominations and recursively find solutions.
  • Cache results to avoid recomputation.

Kotlin Solution:

fun coinChangeMemo(coins: IntArray, amount: Int): Int {
    val memo = mutableMapOf<Int, Int>()

    fun solve(rem: Int): Int {
        if (rem < 0) return -1
        if (rem == 0) return 0
        if (memo.containsKey(rem)) return memo[rem]!!

        var minCoins = Int.MAX_VALUE
        for (coin in coins) {
            val res = solve(rem - coin)
            if (res >= 0 && res < minCoins) {
                minCoins = res + 1
            }
        }

        memo[rem] = if (minCoins == Int.MAX_VALUE) -1 else minCoins
        return memo[rem]!!
    }

    return solve(amount)
}

// Usage:
fun main() {
    println(coinChangeMemo(intArrayOf(1, 2, 5), 11)) // Output: 3
    println(coinChangeMemo(intArrayOf(2), 3))        // Output: -1
    println(coinChangeMemo(intArrayOf(1), 0))        // Output: 0
}

Time Complexity: O(amount * coins.length)
Space Complexity: O(amount) (stack space + memoization map)


Quick Comparison:

Approach Time Complexity Space Complexity When to Use?
Dynamic Programming (Bottom-Up) O(amount * coins.length) O(amount) Optimal, preferred for efficiency
Recursive with Memoization O(amount * coins.length) O(amount) Easy to understand recursion

Edge Cases Handled:

  • If amount is 0, both solutions immediately return 0.
  • If the amount cannot be composed by given coins, they return -1.

Summary:

  • Dynamic Programming is the optimal, most widely used solution for this problem.
  • Recursive Approach with memoization demonstrates understanding of recursion and memoization principles.

You can select either based on clarity, readability, or efficiency needs. The DP solution is highly recommended in competitive programming or technical interviews for optimal performance. 

Thanks for reading! πŸŽ‰ I'd love to know what you think about the article. Did it resonate with you? πŸ’­ Any suggestions for improvement? I’m always open to hearing your feedback to improve my posts! πŸ‘‡πŸš€. Happy coding! πŸ’»✨

Code Challenge: Number of Islands in Kotlin

The Number of Islands problem is a common interview question that involves counting the number of islands in a 2D grid. Each island is made up of connected pieces of land (denoted as '1') surrounded by water (denoted as '0'). The challenge is to count how many separate islands exist in the grid, where an island is formed by horizontally or vertically adjacent lands.



We will discuss multiple ways to solve this problem, explaining their pros and cons. Let's dive into solving this problem using Kotlin.


Problem Definition

Given a 2D binary grid grid, return the number of islands. An island is surrounded by water and is formed by connecting adjacent lands horizontally or vertically. The grid is surrounded by water on all sides.

Example 1:

Input:

[
  ["1", "1", "1", "1", "0"],
  ["1", "1", "0", "1", "0"],
  ["1", "1", "0", "0", "0"],
  ["0", "0", "0", "0", "0"]
]

Output:

1

Example 2:

Input:

[
  ["1", "1", "0", "0", "0"],
  ["1", "1", "0", "0", "0"],
  ["0", "0", "1", "0", "0"],
  ["0", "0", "0", "1", "1"]
]

Output:

3

Approach 1: Depth-First Search (DFS)

The most intuitive approach is to use Depth-First Search (DFS). We start from each land cell ('1'), mark it as visited (or change it to water '0'), and recursively check its adjacent cells (up, down, left, right). Every time we find an unvisited land cell, we count it as a new island.

Algorithm:

  1. Traverse the grid.
  2. If we find a '1', increment the island count and use DFS to mark the entire island as visited.
  3. For each DFS, recursively mark the neighboring land cells.

Kotlin Implementation:

fun numIslands(grid: Array<CharArray>): Int {
    if (grid.isEmpty()) return 0
    var count = 0

    // Define DFS function
    fun dfs(grid: Array<CharArray>, i: Int, j: Int) {
        // Return if out of bounds or at water
        if (i < 0 || i >= grid.size || j < 0 || j >= grid[0].size || grid[i][j] == '0') return
        // Mark the land as visited
        grid[i][j] = '0'
        // Visit all 4 adjacent cells
        dfs(grid, i + 1, j) // down
        dfs(grid, i - 1, j) // up
        dfs(grid, i, j + 1) // right
        dfs(grid, i, j - 1) // left
    }

    // Iterate over the grid
    for (i in grid.indices) {
        for (j in grid[i].indices) {
            if (grid[i][j] == '1') {
                // Found a new island
                count++
                dfs(grid, i, j)
            }
        }
    }
    return count
}

Time Complexity:

  • O(m * n), where m is the number of rows and n is the number of columns. Each cell is visited once.

Space Complexity:

  • O(m * n) in the worst case (if the entire grid is land), as we may need to store all cells in the call stack due to recursion.

Approach 2: Breadth-First Search (BFS)

We can also use Breadth-First Search (BFS). Instead of using recursion like in DFS, we use a queue to explore all adjacent cells iteratively. The process is similar, but the main difference lies in the order of exploration.

Algorithm:

  1. Start from an unvisited land cell ('1').
  2. Use a queue to explore all adjacent land cells and mark them as visited.
  3. Each BFS initiation represents a new island.

Kotlin Implementation:

fun numIslands(grid: Array<CharArray>): Int {
    if (grid.isEmpty()) return 0
    var count = 0
    val directions = arrayOf(intArrayOf(0, 1), intArrayOf(1, 0), intArrayOf(0, -1), intArrayOf(-1, 0))

    fun bfs(i: Int, j: Int) {
        val queue: LinkedList<Pair<Int, Int>>= LinkedList()
        queue.offer(Pair(i, j))
        grid[i][j] = '0' // Mark the starting cell as visited

        while (queue.isNotEmpty()) {
            val (x, y) = queue.poll()
            for (dir in directions) {
                val newX = x + dir[0]
                val newY = y + dir[1]
                if (newX in grid.indices && newY in grid[0].indices && grid[newX][newY] == '1') {
                    grid[newX][newY] = '0' // Mark as visited
                    queue.offer(Pair(newX, newY))
                }
            }
        }
    }

    for (i in grid.indices) {
        for (j in grid[i].indices) {
            if (grid[i][j] == '1') {
                count++
                bfs(i, j)
            }
        }
    }
    return count
}

Time Complexity:

  • O(m * n), where m is the number of rows and n is the number of columns. Each cell is visited once.

Space Complexity:

  • O(m * n), which is required for the queue in the worst case.

Approach 3: Union-Find (Disjoint Set)

The Union-Find (or Disjoint Set) approach is another efficient way to solve this problem. The idea is to treat each land cell as an individual component and then union adjacent land cells. Once all unions are complete, the number of islands is simply the number of disjoint sets.

Algorithm:

  1. Initialize each land cell as a separate island.
  2. For each neighboring land cell, perform a union operation.
  3. The number of islands will be the number of disjoint sets.

Kotlin Implementation:

class UnionFind(private val m: Int, private val n: Int) {
    private val parent = IntArray(m * n) { it }

    fun find(x: Int): Int {
        if (parent[x] != x) parent[x] = find(parent[x]) // Path compression
        return parent[x]
    }

    fun union(x: Int, y: Int) {
        val rootX = find(x)
        val rootY = find(y)
        if (rootX != rootY) parent[rootX] = rootY
    }

    fun getCount(): Int {
        return parent.count { it == it }
    }
}

fun numIslands(grid: Array<CharArray>): Int {
    if (grid.isEmpty()) return 0
    val m = grid.size
    val n = grid[0].size
    val uf = UnionFind(m, n)

    for (i in grid.indices) {
        for (j in grid[i].indices) {
            if (grid[i][j] == '1') {
                val index = i * n + j
                // Try to union with adjacent cells
                if (i + 1 &lt; m &amp;&amp; grid[i + 1][j] == '1') uf.union(index, (i + 1) * n + j)
                if (j + 1 &lt; n &amp;&amp; grid[i][j + 1] == '1') uf.union(index, i * n + (j + 1))
            }
        }
    }
    val islands = mutableSetOf&lt;Int&gt;()
    for (i in grid.indices) {
        for (j in grid[i].indices) {
            if (grid[i][j] == '1') {
                islands.add(uf.find(i * n + j))
            }
        }
    }
    return islands.size
}

Time Complexity:

  • O(m * n), as we perform a union operation for each adjacent land cell.

Space Complexity:

  • O(m * n) for the union-find parent array.

Calling in main():

fun main() {
    val grid1 = arrayOf(
        charArrayOf('1', '1', '1', '1', '0'),
        charArrayOf('1', '1', '0', '1', '0'),
        charArrayOf('1', '1', '0', '0', '0'),
        charArrayOf('0', '0', '0', '0', '0')
    )
    println("Number of Islands : ${numIslands(grid1)}")  // Output: 1
    
    val grid2 = arrayOf(
        charArrayOf('1', '1', '0', '0', '0'),
        charArrayOf('1', '1', '0', '0', '0'),
        charArrayOf('0', '0', '1', '0', '0'),
        charArrayOf('0', '0', '0', '1', '1')
    )
    println("Number of Islands : ${numIslands(grid2)}")  // Output: 3
}



Which Solution is Best?

  1. DFS/BFS (Approaches 1 & 2): These are the simplest and most intuitive solutions. Both have a time complexity of O(m * n), which is optimal for this problem. DFS uses recursion, which might run into issues with large grids due to stack overflow, but BFS avoids this problem by using an iterative approach. If you want simplicity and reliability, BFS is preferred.

  2. Union-Find (Approach 3): This approach is more advanced and has a similar time complexity of O(m * n). However, it can be more difficult to understand and implement. It also performs well with path compression and union by rank, but for this problem, the DFS/BFS approach is usually sufficient and easier to implement.

Conclusion

For this problem, BFS is the recommended solution due to its iterative nature, which avoids recursion issues with large grids, while still being efficient and easy to understand.


Full Problem description in LeetCode


Thank you for reading my latest article! I would greatly appreciate your feedback to improve my future posts. πŸ’¬ Was the information clear and valuable? Are there any areas you think could be improved? Please share your thoughts in the comments or reach out directly. Your insights are highly valued. πŸ‘‡πŸ˜Š.  Happy coding! πŸ’»✨