Amol Pawar

Process Death in Android

What Is Process Death in Android? Causes, Examples, and How to Handle It

Have you ever opened an app on your Android phone, switched to another app for a while, and then returned only to find everything reset? Your form data gone, your scroll position lost, or the app back at the home screen?

That’s Process Death in Android.

Understanding Process Death in Android is crucial for building apps that feel reliable and professional. In this guide, we’ll explore what it is, why it happens, and most importantly, how to handle it properly in your Android apps.

What Exactly Is Process Death in Android?

Process Death in Android occurs when the Android operating system kills your app’s process to free up memory for other apps. Think of it like your phone doing some housekeeping — when memory gets tight, Android decides which apps to close to keep everything running smoothly.

Here’s the tricky part: when your app’s process dies, your activities might still appear to be in the back stack. When the user returns, Android recreates the activity, but all your runtime data is gone unless you’ve saved it properly.

This is different from the normal activity lifecycle. Your app doesn’t just pause or stop — it’s completely terminated and then brought back to life.

Why Does Process Death Happen?

Android needs to manage limited device resources efficiently. Here are the main reasons Process Death in Android occurs:

Low Memory Situations

When your device runs low on RAM, Android starts killing background processes. Apps you haven’t used recently are the first to go.

Developer Options Testing

During development, you can enable “Don’t keep activities” in Developer Options. This immediately destroys activities when they leave the foreground, making it easier to test Process Death scenarios.

Long Background Duration

If your app stays in the background for an extended period while the user uses other memory-intensive apps, there’s a higher chance your process will be killed.

System Updates or Crashes

Sometimes system events or crashes can trigger process termination across multiple apps.

Real-World Example: The Shopping Cart Problem

Let me share a common scenario that illustrates Process Death in Android perfectly.

Imagine you’re building a shopping app. A user adds five items to their cart, then switches to their messaging app to check a friend’s recommendation. They spend 10 minutes chatting, during which Android kills your shopping app’s process to free memory.

When they return to your app, if you haven’t handled Process Death properly, their cart is empty. 

Frustrating, right?

This is exactly why understanding and handling Process Death in Android matters for user experience.

How to Detect Process Death in Android

Before we fix it, let’s learn how to detect it. Add this code to your activity:

Kotlin
class MainActivity : AppCompatActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        if (savedInstanceState != null) {
            // Activity was recreated after process death
            Log.d("ProcessDeath", "Activity restored after process death")
        } else {
            // Normal first-time creation
            Log.d("ProcessDeath", "Activity created normally")
        }
    }
}

The onCreate() method receives a savedInstanceState parameter. When this parameter is not null, it means Android is restoring your activity after Process Death. If it’s null, your activity is being created for the first time or normally resumed.

This simple check helps you understand when restoration is happening.

Saving State with onSaveInstanceState

The primary way to handle Process Death in Android is by overriding onSaveInstanceState(). This method is called before your activity might be destroyed, giving you a chance to save important data.

Kotlin
class ShoppingCartActivity : AppCompatActivity() {
    
    private var cartItems = mutableListOf<String>()
    private var totalPrice = 0.0
    private var userName = ""
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_shopping_cart)
        
        // Restore saved state if available
        savedInstanceState?.let {
            cartItems = it.getStringArrayList("CART_ITEMS")?.toMutableList() ?: mutableListOf()
            totalPrice = it.getDouble("TOTAL_PRICE", 0.0)
            userName = it.getString("USER_NAME", "")
            
            Log.d("ProcessDeath", "Restored ${cartItems.size} items")
        }
        
        updateUI()
    }
    
    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        
        // Save critical data before potential process death
        outState.putStringArrayList("CART_ITEMS", ArrayList(cartItems))
        outState.putDouble("TOTAL_PRICE", totalPrice)
        outState.putString("USER_NAME", userName)
        
        Log.d("ProcessDeath", "Saved ${cartItems.size} items to bundle")
    }
    
    private fun updateUI() {
        // Update your UI with restored data
        findViewById<TextView>(R.id.itemCount).text = "Items: ${cartItems.size}"
        findViewById<TextView>(R.id.totalPrice).text = "Total: $$totalPrice"
    }
}

Here, we override two key methods:

  1. onSaveInstanceState(): This is where we save our data to a Bundle. Think of a Bundle as a container that Android keeps safe even during Process Death. We use methods like putStringArrayList(), putDouble(), and putString() to store different data types.
  2. onCreate(): We check if savedInstanceState exists. If it does, we restore our data using corresponding get methods like getStringArrayList() and getDouble().

The ?.let syntax is Kotlin’s safe call operator, ensuring we only access the bundle if it’s not null.

What Data Can You Save in a Bundle?

Bundles support many common data types, but there are limitations. Here’s what you can save:

Supported Types:

  • Primitive types (Int, Long, Float, Double, Boolean)
  • Strings and CharSequences
  • Parcelable objects
  • Serializable objects
  • Arrays and ArrayLists of supported types

Important Limitation: Bundles have a size limit (typically around 500KB to 1MB). Don’t try to save large images, videos, or extensive datasets. For large data, use other persistence methods like databases or files.

Using ViewModel to Survive Configuration Changes

While onSaveInstanceState() handles Process Death in Android, ViewModels help with configuration changes like screen rotation. However, ViewModels alone don’t survive process death.

Here’s how to combine both approaches:

Kotlin
class UserProfileViewModel : ViewModel() {
    
    // This survives configuration changes but NOT process death
    var userName = MutableLiveData<String>()
    var userAge = MutableLiveData<Int>()
    var profileImageUrl = MutableLiveData<String>()
    
    fun updateUserData(name: String, age: Int, imageUrl: String) {
        userName.value = name
        userAge.value = age
        profileImageUrl.value = imageUrl
    }
}

class UserProfileActivity : AppCompatActivity() {
    
    private lateinit var viewModel: UserProfileViewModel
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_user_profile)
        
        viewModel = ViewModelProvider(this).get(UserProfileViewModel::class.java)
        
        // If recovering from process death, restore to ViewModel
        savedInstanceState?.let {
            val name = it.getString("USER_NAME", "")
            val age = it.getInt("USER_AGE", 0)
            val imageUrl = it.getString("PROFILE_IMAGE_URL", "")
            
            viewModel.updateUserData(name, age, imageUrl)
        }
        
        observeViewModel()
    }
    
    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        
        // Save ViewModel data to survive process death
        outState.putString("USER_NAME", viewModel.userName.value ?: "")
        outState.putInt("USER_AGE", viewModel.userAge.value ?: 0)
        outState.putString("PROFILE_IMAGE_URL", viewModel.profileImageUrl.value ?: "")
    }
    
    private fun observeViewModel() {
        viewModel.userName.observe(this) { name ->
            findViewById<TextView>(R.id.nameText).text = name
        }
        
        viewModel.userAge.observe(this) { age ->
            findViewById<TextView>(R.id.ageText).text = "Age: $age"
        }
    }
}

The ViewModel holds our UI data and survives configuration changes automatically. However, to survive Process Death in Android, we still need to:

  1. Save ViewModel data in onSaveInstanceState()
  2. Restore that data back to the ViewModel in onCreate()

This gives us the best of both worlds: automatic configuration change handling from ViewModel, plus process death recovery from saved instance state.

SavedStateHandle: The Modern Approach

Android Jetpack provides SavedStateHandle, which combines ViewModel benefits with automatic state saving. This is the recommended approach for handling Process Death in Android:

Kotlin
class ShoppingViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    
    // Automatically saved and restored across process death
    var cartItems: MutableLiveData<List<String>> = savedStateHandle.getLiveData("cart_items", emptyList())
    var totalPrice: MutableLiveData<Double> = savedStateHandle.getLiveData("total_price", 0.0)
    
    fun addItem(item: String, price: Double) {
        val currentItems = cartItems.value?.toMutableList() ?: mutableListOf()
        currentItems.add(item)
        cartItems.value = currentItems
        
        val currentTotal = totalPrice.value ?: 0.0
        totalPrice.value = currentTotal + price
        
        // Automatically saved to SavedStateHandle
    }
    
    fun clearCart() {
        cartItems.value = emptyList()
        totalPrice.value = 0.0
    }
}


class ShoppingActivity : AppCompatActivity() {
    
    private val viewModel: ShoppingViewModel by viewModels()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_shopping)
        
        // No manual state restoration needed!
        // SavedStateHandle does it automatically
        
        viewModel.cartItems.observe(this) { items ->
            updateCartUI(items)
        }
        
        viewModel.totalPrice.observe(this) { total ->
            findViewById<TextView>(R.id.totalText).text = "Total: $$total"
        }
        
        findViewById<Button>(R.id.addButton).setOnClickListener {
            viewModel.addItem("Product ${System.currentTimeMillis()}", 29.99)
        }
    }
    
    private fun updateCartUI(items: List<String>) {
        // Update RecyclerView or ListView with items
    }
}

SavedStateHandle is magical for handling Process Death in Android. Here’s why:

  1. getLiveData(): This method creates LiveData that’s automatically backed by saved state. When process death occurs, the data is saved. When the process restarts, the data is restored automatically.
  2. No manual saving: Unlike the previous examples, we don’t need to override onSaveInstanceState(). The SavedStateHandle does it for us.
  3. Type-safe: We can store various types, and they’re automatically serialized and deserialized.

The by viewModels() delegate is a Kotlin property delegate that creates or retrieves the ViewModel with SavedStateHandle automatically injected.

Testing Process Death in Android

Testing is crucial to ensure your app handles Process Death in Android correctly. Here are practical ways to test:

Method 1: Developer Options

  1. Go to Settings → Developer Options
  2. Enable “Don’t keep activities”
  3. Navigate through your app, switching between activities
  4. Every time an activity goes to the background, it’s destroyed

Method 2: Using ADB Command

Force your app’s process to be killed using Android Debug Bridge:

adb shell am kill com.yourapp.package

Then return to your app from the recent apps menu.

Method 3: Memory Pressure Testing

Use Android Studio’s Profiler to simulate low memory conditions:

  1. Open Android Profiler
  2. Select Memory
  3. Click “Force garbage collection” multiple times
  4. Android may kill your app’s process naturally

Common Mistakes to Avoid

When dealing with Process Death in Android, developers often make these mistakes:

Mistake 1: Saving Large Objects

Kotlin
// DON'T DO THIS
override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putSerializable("LARGE_IMAGE", largeImageBitmap) // Too big!
}

Why it’s wrong: Bundles have size limits. Saving large objects causes TransactionTooLargeException.

Better approach: Save only a reference or ID, then reload the data from a persistent source.

Kotlin
// DO THIS INSTEAD
override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putString("IMAGE_URL", imageUrl) // Save URL, not bitmap
}

Mistake 2: Assuming ViewModel Survives Process Death

Kotlin
// INCORRECT ASSUMPTION
class MyActivity : AppCompatActivity() {
    private lateinit var viewModel: MyViewModel
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewModel = ViewModelProvider(this).get(MyViewModel::class.java)
        
        // Assuming viewModel.userData is always available after process death
        // This is WRONG - ViewModel doesn't survive process death without SavedStateHandle
    }
}

Fix: Use SavedStateHandle or manually save/restore ViewModel data.

Mistake 3: Not Testing Thoroughly

Many developers never test Process Death scenarios until users report bugs. Always enable “Don’t keep activities” during development.

Best Practices for Handling Process Death in Android

1. Use SavedStateHandle for Simple Data

For primitive types and small objects, SavedStateHandle is your best friend:

Kotlin
class MyViewModel(private val state: SavedStateHandle) : ViewModel() {
    val username: LiveData<String> = state.getLiveData("username", "")
    val score: LiveData<Int> = state.getLiveData("score", 0)
}

2. Persist Important Data to Database

For critical data that users can’t afford to lose, use Room database or other persistent storage:

Kotlin
class FormActivity : AppCompatActivity() {
    private lateinit var database: AppDatabase
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        database = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java,
            "form-database"
        ).build()
        
        // Restore form from database if exists
        lifecycleScope.launch {
            val savedForm = database.formDao().getUnsubmittedForm()
            savedForm?.let { restoreForm(it) }
        }
    }
    
    override fun onPause() {
        super.onPause()
        // Save form to database when leaving activity
        lifecycleScope.launch {
            val formData = collectFormData()
            database.formDao().saveForm(formData)
        }
    }
}

3. Combine Multiple Approaches

Use the right tool for each type of data:

  • SavedStateHandle: UI state (scroll position, selected tab, form inputs)
  • ViewModel: Temporary runtime data that survives configuration changes
  • Database/SharedPreferences: Persistent user data
  • Memory cache: Easily re-fetchable data

4. Keep Bundles Small

Only save essential state information. Calculate or reload other data:

Kotlin
override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    
    // Save only IDs, not entire objects
    outState.putInt("SELECTED_ITEM_ID", selectedItem.id)
    outState.putString("SEARCH_QUERY", searchQuery)
    
    // Don't save the entire search results list
    // Reload it using the search query instead
}

Advanced: Handling Process Death with Compose

If you’re using Jetpack Compose, handling Process Death in Android looks a bit different:

Kotlin
@Composable
fun ShoppingCartScreen(viewModel: ShoppingViewModel = viewModel()) {
    
    val cartItems by viewModel.cartItems.observeAsState(emptyList())
    val totalPrice by viewModel.totalPrice.observeAsState(0.0)
    
    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        Text(
            text = "Shopping Cart",
            style = MaterialTheme.typography.h5
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        LazyColumn(modifier = Modifier.weight(1f)) {
            items(cartItems) { item ->
                CartItemRow(item = item)
            }
        }
        
        Divider()
        
        Text(
            text = "Total: $${"%.2f".format(totalPrice)}",
            style = MaterialTheme.typography.h6,
            modifier = Modifier.padding(vertical = 16.dp)
        )
        
        Button(
            onClick = { viewModel.addItem("New Product", 29.99) },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Add Item")
        }
    }
}

@Composable
fun CartItemRow(item: String) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 8.dp),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(text = item)
        Text(text = "$29.99")
    }
}

With Compose, you still use ViewModel with SavedStateHandle. The difference is:

  1. observeAsState(): Converts LiveData from ViewModel into Compose State
  2. Automatic recomposition: When the ViewModel data changes (including after process death restoration), Compose automatically updates the UI
  3. No manual lifecycle management: Compose handles the observation lifecycle for you

The underlying Process Death handling still uses SavedStateHandle in the ViewModel, but the UI layer becomes much simpler.

Monitoring Process Death in Production

To understand how often Process Death in Android affects your users, implement analytics:

Kotlin
class AnalyticsHelper(private val context: Context) {
    
    fun trackProcessDeath(activityName: String) {
        // Log to your analytics service
        FirebaseAnalytics.getInstance(context).logEvent("process_death_recovery") {
            param("activity_name", activityName)
            param("timestamp", System.currentTimeMillis())
        }
    }
}

class MainActivity : AppCompatActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        if (savedInstanceState != null) {
            AnalyticsHelper(this).trackProcessDeath("MainActivity")
        }
    }
}

This helps you understand:

  • How frequently users experience process death
  • Which activities are most affected
  • Whether users successfully recover their state

Conclusion

Process Death in Android is an essential concept every Android developer must master. It’s not just about preventing crashes — it’s about creating a seamless user experience that feels reliable and polished.

Remember these key takeaways:

For simple UI state: Use SavedStateHandle with ViewModel. It automatically handles Process Death in Android with minimal code.

For important user data: Persist to a database. Don’t rely solely on saved instance state for data users can’t afford to lose.

Always test: Enable “Don’t keep activities” during development. Test your app thoroughly by simulating process death scenarios.

Keep bundles small: Only save essential state information. Reload complex data when the activity restores.

By properly handling Process Death in Android, you’ll build apps that feel professional, reliable, and respectful of your users’ time and data. Your users might never know about the complexity you’ve handled behind the scenes — and that’s exactly the point.

Start implementing these patterns in your next project, and you’ll be amazed at how much more robust your apps become. 

Composition Over Inheritance

What Is Composition Over Inheritance? The Built-In Compose Way Explained

If you’ve been writing object-oriented code for a while, you’ve probably used inheritance a lot. It feels natural. You create a base class, extend it, override a few methods, and move on.

But as projects grow, inheritance often becomes hard to manage. Classes get tightly coupled. Changes ripple through the codebase. Small tweaks break unexpected things.

This is where Composition Over Inheritance comes in.

In this post, we’ll break down what Composition Over Inheritance really means, why it matters, and how it’s used naturally in modern Kotlin development, especially with Jetpack Compose. 

What Does “Composition Over Inheritance” Mean?

Composition Over Inheritance is a design principle that says:

Prefer building classes by combining smaller, reusable components instead of extending base classes.

In simpler terms:

  • Inheritance says “is a”
  • Composition says “has a”

Instead of forcing behavior through class hierarchies, you compose behavior by using other objects.

A Simple Real-World Example

Think of a smartphone.

A smartphone has a camera, battery, speaker, and screen.

It does not inherit from Camera, Battery, or Speaker.

That’s composition.

If you used inheritance here, the design would fall apart fast.

The Problem With Inheritance

Inheritance looks clean at first, but it comes with hidden costs.

Example Using Inheritance (Problematic)

Kotlin
open class Vehicle {
    open fun move() {
        println("Vehicle is moving")
    }
}

open class Car : Vehicle() {
    override fun move() {
        println("Car is driving")
    }
}

class ElectricCar : Car() {
    override fun move() {
        println("Electric car is driving silently")
    }
}

At first glance, this seems fine.

But now imagine:

  • You want a flying car
  • You want a boat-car
  • You want a self-driving electric truck

Your inheritance tree explodes.

Changes to Vehicle affect every subclass. You’re locked into decisions you made early, often before requirements were clear.

This is exactly what Composition Over Inheritance helps you avoid.

Composition Over Inheritance Explained With Kotlin

Let’s rewrite the same idea using composition.

Create Small, Focused Behaviors

Kotlin
interface Engine {
    fun move()
}
Kotlin
class GasEngine : Engine {
    override fun move() {
        println("Driving using gas engine")
    }
}
Kotlin
class ElectricEngine : Engine {
    override fun move() {
        println("Driving silently using electric engine")
    }
}

Each class has one clear responsibility.

Compose the Behavior

Kotlin
class Car(private val engine: Engine) {

    fun drive() {
        engine.move()
    }
}

Now the Car has an engine, instead of being forced into a rigid hierarchy.

Use It

Kotlin
fun main() {
    val electricCar = Car(ElectricEngine())
    electricCar.drive()

    val gasCar = Car(GasEngine())
    gasCar.drive()
}

Output:

Driving silently using electric engine
Driving using gas engine

This is Composition Over Inheritance in action.

Why Composition Over Inheritance Is Better

Here’s why modern Kotlin developers strongly prefer this approach.

1. Less Coupling

Your classes depend on interfaces, not concrete implementations.

2. Easier Changes

You can swap behaviors without rewriting class hierarchies.

3. Better Testability

You can inject fake or mock implementations easily.

4. Cleaner Code

Smaller classes. Clear responsibilities. Fewer surprises.

Composition Over Inheritance in Jetpack Compose

Jetpack Compose is built almost entirely on Composition Over Inheritance.

That’s not an accident.

Traditional UI (Inheritance-Heavy)

Kotlin
class CustomButton : Button {
    // override styles, behavior, states
}

This leads to rigid UI components that are hard to reuse.

Compose Way (Composition First)

Kotlin
@Composable
fun MyButton(
    text: String,
    onClick: () -> Unit
) {
    Button(onClick = onClick) {
        Text(text)
    }
}

Here’s what’s happening:

  • MyButton is not extending Button
  • It uses Button
  • Behavior is passed in, not inherited

This is Composition Over Inheritance at the UI level.

Why Compose Feels Easier to Work With

Compose avoids deep inheritance trees entirely.

Instead:

  • UI is built from small composable functions
  • Each function does one thing
  • You combine them like building blocks

That’s composition by design.

Delegation: Kotlin’s Built-In Support for Composition

Kotlin makes Composition Over Inheritance even easier with delegation.

Example Using Delegation

Kotlin
interface Logger {
    fun log(message: String)
}

class ConsoleLogger : Logger {
    override fun log(message: String) {
        println(message)
    }
}

class UserService(private val logger: Logger) : Logger by logger

Now UserService automatically uses ConsoleLogger’s implementation without inheritance.

This keeps your code flexible and clean.

When Should You Still Use Inheritance?

Inheritance is not evil. It’s just often overused.

Inheritance works best when:

  • There is a true “is-a” relationship
  • The base class is stable
  • You control both parent and child classes

If those conditions are missing, Composition Over Inheritance is usually the safer choice.

Conclusion

Let’s wrap it up.

  • Composition Over Inheritance means building behavior using objects, not class hierarchies
  • Kotlin makes composition easy with interfaces and delegation
  • Jetpack Compose is a real-world example of this principle done right
  • Composition leads to flexible, testable, and maintainable code

If you’re writing Kotlin today, especially with Compose, you’re already using Composition Over Inheritance whether you realized it or not.

And once you start designing with it intentionally, your code gets simpler, not harder.

Architecting Alarms & Notifications in Android

Architecting Alarms & Notifications in Android: The Clean Architecture Way

Alarms, reminders, and notifications are some of the most deceptively complex features in Android development.

On the surface, they look simple: “Schedule something at a time and show a notification.”

In reality, you’re fighting with:

  • Doze mode
  • OEM background restrictions
  • Process death
  • App restarts
  • Device reboots
  • Android 12+ background execution limits
  • Android 13+ notification permissions
  • Android 14+ stricter alarms policies

After more than a decade building Android apps at scale, one thing is clear:

Most alarm and notification bugs are architectural bugs, not API bugs.

This article walks through how to architect alarms and notifications in a real production app using MVVM + Clean Architecture, feature modules, and a hybrid AlarmManager + WorkManager approach that survives modern Android constraints.

This is not theory. This is how you ship reliable reminder systems.

Core Architectural Principles (Hard-Earned Lessons)

Before talking about code, we need to align on principles. These are non-negotiable.

  1. Business logic must not depend on Android
  2. Scheduling is a domain concern, execution is an infrastructure concern
  3. Alarms wake the app, Workers do the work
  4. Repeating alarms are not repeating system alarms
  5. Everything must survive process death and reboot

If your current implementation violates any of these, instability is inevitable.

High-Level Architecture Overview

At a conceptual level, the flow looks like this:

Kotlin
UI → ViewModel → UseCase → Domain → Scheduler abstraction

                               Android implementation

                          AlarmManager → Receiver → Worker

                                Notification

Each arrow represents a boundary. Boundaries are what give us control.

Feature-Based Modular Architecture

In real apps, packages are not enough. Gradle modules matter.

Recommended Module Layout

Kotlin
:app

:core:domain
:core:data
:core:infra
:core:common

:feature:reminder
:feature:notification

Why This Matters

  • Domain becomes completely platform-independent
  • Feature modules own UI and presentation logic
  • Infra absorbs Android volatility
  • Teams can work independently without merge hell

Dependency Rules (Strict)

Kotlin
feature → domain
data → domain
infra → domain + data
app → everything

If a feature imports AlarmManager, your architecture is already compromised.

Domain Layer: Where Truth Lives

The domain layer is pure Kotlin. No Context. No Android imports.

Reminder Model

Kotlin
data class Reminder(
    val id: String,
    val triggerAtMillis: Long,
    val title: String,
    val message: String,
    val repeat: RepeatRule?
)

This model expresses intent, not implementation.

Repeat Rule Modeling (Critical Design)

Repeating reminders are the most common source of bugs. The fix starts here.

Kotlin
sealed class RepeatRule {
    data class Daily(val intervalDays: Int = 1) : RepeatRule()
    data class Weekly(val daysOfWeek: Set<DayOfWeek>) : RepeatRule()
    data class Interval(val millis: Long) : RepeatRule()
}

This gives us:

  • Explicit behavior
  • Full control
  • Deterministic scheduling

Scheduler Abstraction

The domain does not care how scheduling happens.

Kotlin
interface ReminderScheduler {
    fun schedule(reminder: Reminder)
    fun cancel(reminderId: String)
}

This single interface is what keeps Android chaos contained.

Use Case Example

Kotlin
class ScheduleReminderUseCase(
    private val repository: ReminderRepository,
    private val scheduler: ReminderScheduler
) {
    suspend operator fun invoke(reminder: Reminder) {
        repository.save(reminder)
        scheduler.schedule(reminder)
    }
}

Notice the order:

  1. Persist
  2. Schedule

That one decision is the difference between recoverable and broken reminders.

Presentation Layer (MVVM Done Properly)

The ViewModel knows nothing about alarms or notifications.

Kotlin
class ReminderViewModel(
    private val scheduleReminder: ScheduleReminderUseCase
) : ViewModel() {

   fun schedule(time: Long, title: String) {
        viewModelScope.launch {
            scheduleReminder(
                Reminder(
                    id = UUID.randomUUID().toString(),
                    triggerAtMillis = time,
                    title = title,
                    message = "Reminder",
                    repeat = null
                )
            )
        }
    }
}

This is clean, testable, and boring — exactly how ViewModels should be.

Infrastructure Layer: Where Android Lives

This is the layer that absorbs:

  • AlarmManager
  • PendingIntent
  • BroadcastReceiver
  • WorkManager
  • Notifications

And keeps them out of your business logic.

Alarm Scheduling Implementation

Kotlin
class AndroidReminderScheduler(
    private val alarmManager: AlarmManager,
    private val pendingIntentFactory: ReminderPendingIntentFactory
) : ReminderScheduler {

    override fun schedule(reminder: Reminder) {
        alarmManager.setExactAndAllowWhileIdle(
            AlarmManager.RTC_WAKEUP,
            reminder.triggerAtMillis,
            pendingIntentFactory.create(reminder.id)
        )
    }

    override fun cancel(reminderId: String) {
        alarmManager.cancel(
            pendingIntentFactory.create(reminderId)
        )
    }
}

We use:

  • setExactAndAllowWhileIdle
  • Explicit PendingIntent
  • One alarm per reminder

Anything else is unreliable.

PendingIntent Factory (Often Overlooked)

Kotlin
class ReminderPendingIntentFactory(
    private val context: Context
) {
    fun create(id: String): PendingIntent {
        val intent = Intent(context, ReminderAlarmReceiver::class.java)
            .putExtra("REMINDER_ID", id)

        return PendingIntent.getBroadcast(
            context,
            id.hashCode(),
            intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
    }
}

Correct requestCode usage is essential for cancellation and updates.

Alarm Receiver: Do Almost Nothing

Kotlin
class ReminderAlarmReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val work = OneTimeWorkRequestBuilder<ReminderWorker>()
            .setInputData(workDataOf(
                "REMINDER_ID" to intent.getStringExtra("REMINDER_ID")
            ))
            .build()

        WorkManager.getInstance(context).enqueue(work)
    }
}

Receivers should delegate immediately. Anything else risks ANRs.

Worker: The Real Execution Engine

This is where notifications are shown and repeats are handled.

Kotlin
@HiltWorker
class ReminderWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val repository: ReminderRepository,
    private val scheduler: ReminderScheduler,
    private val calculator: NextTriggerCalculator
) : CoroutineWorker(context, params) {

     override suspend fun doWork(): Result {
        val id = inputData.getString("REMINDER_ID") ?: return Result.failure()
        val reminder = repository.getById(id) ?: return Result.failure()
        showNotification(reminder)
        reminder.repeat?.let {
            val next = calculator.calculate(reminder.triggerAtMillis, it)
            val updated = reminder.copy(triggerAtMillis = next)
            repository.save(updated)
            scheduler.schedule(updated)
        }
        return Result.success()
    }
}

This explicit rescheduling is what makes repeating reminders reliable.

Why Repeating Alarms Must Be Manual

Using setRepeating():

  • Is inexact
  • Breaks under Doze
  • Is ignored by OEMs
  • Cannot adapt dynamically

One alarm → one execution → reschedule next

This model survives every Android version.

Hilt Dependency Injection (Exact Bindings)

Scheduler Binding

Kotlin
@Module
@InstallIn(SingletonComponent::class)
object SchedulerModule {

    @Provides
    fun provideReminderScheduler(
        alarmManager: AlarmManager,
        factory: ReminderPendingIntentFactory
    ): ReminderScheduler =
        AndroidReminderScheduler(alarmManager, factory)
}

AlarmManager Binding

Kotlin
@Module
@InstallIn(SingletonComponent::class)
object SystemServiceModule {

    @Provides
    fun provideAlarmManager(
        @ApplicationContext context: Context
    ): AlarmManager =
        context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
}

Domain remains DI-agnostic.

AlarmManager vs WorkManager: The Real Decision Matrix

RequirementAlarmManagerWorkManager
Exact timingProvides precise, exact-time execution and is suitable for alarms and reminders that must fire at a specific momentDoes not guarantee exact timing; execution can be delayed based on system conditions
Doze compatibilityCan fire during Doze mode only when using exact alarms with allow-while-idle, and even then execution is constrainedDoes not run during Doze at an exact time and may be deferred until the system exits idle mode
Long background workNot suitable for long-running background tasks; execution time must be very shortDesigned specifically for longer background work with proper lifecycle handling
Survives device rebootDoes not automatically survive a device reboot and requires manual reschedulingAutomatically survives device reboot and restores scheduled work
Battery efficiencyLess battery efficient because it bypasses system optimizations for exact timingMore battery efficient because it respects system scheduling and optimization policies
User-visible alertsWell suited for user-visible actions such as alarms, reminders, and time-critical notificationsLess reliable for user-visible alerts that must appear at an exact time
  • Use AlarmManager when the user expects something to happen at an exact moment, such as reminders, alarms, or medication alerts.
  • Use WorkManager when the task involves background processing, network calls, database work, or any operation that does not require exact timing.
  • Combine both for production-grade systems: let AlarmManager wake the app, and let WorkManager perform the actual work.

The Correct Hybrid Approach

  1. AlarmManager wakes the app at the exact time
  2. WorkManager executes background work
  3. Worker shows notification and reschedules

This combination works across:

  • Android 8 → 15
  • OEM restrictions
  • Play Store policies

Conclusion

Reliable alarms and notifications are not about clever tricks or undocumented APIs. They are about respecting architectural boundaries and accepting Android’s reality.

If you:

  • Keep domain pure
  • Treat scheduling as a business concern
  • Let infra absorb platform volatility
  • Reschedule explicitly

Your reminder system will survive every Android release.

And you’ll sleep better knowing your users won’t miss their alarms.

Factory Pattern Simplify Complex Object Creation

How Does the Factory Pattern Simplify Complex Object Creation in Modern Applications?

Creating objects sounds simple at first. You call a constructor, pass a few values, and move on.

But as applications grow, object creation often becomes messy.

You start seeing:

  • Too many if-else or when blocks
  • Classes tightly coupled to concrete implementations
  • Code that’s hard to test, extend, or understand

This is exactly where the Factory Pattern Simplify Complex Object Creation problem in modern applications.

Let’s break it down in a clear and practical way.

What Is the Factory Pattern?

The Factory Pattern is a creational design pattern that handles object creation for you.

Instead of creating objects directly using constructors, you delegate that responsibility to a factory.

In simple terms:

A factory decides which object to create and how to create it.

Your main code just asks for an object and uses it. It doesn’t care about the details.

This separation is what makes the Factory Pattern Simplify Complex Object Creation so effective.

Why Object Creation Becomes Complex

In real-world applications, object creation often depends on:

  • User input
  • Configuration files
  • API responses
  • Environment (development, testing, production)

Example without a factory:

Kotlin
val paymentProcessor = when (paymentType) {
    "CARD" -> CardPaymentProcessor()
    "UPI" -> UpiPaymentProcessor()
    "WALLET" -> WalletPaymentProcessor()
    else -> throw IllegalArgumentException("Invalid payment type")
}

Now imagine this logic repeated across multiple files.

Problems appear quickly:

  • Code duplication
  • Hard-to-maintain logic
  • Difficult testing
  • Violations of the Single Responsibility Principle

This is why developers rely on patterns that simplify complex object creation.

How the Factory Pattern Helps

The Factory Pattern solves these issues by:

  • Centralizing object creation
  • Reducing tight coupling
  • Making code easier to extend
  • Improving testability

Most importantly, it lets your business logic focus on what to do, not how objects are created.

That’s the real power behind Factory Pattern Simplify Complex Object Creation.

A Simple Kotlin Example

Define a Common Interface

Kotlin
interface Notification {
    fun send(message: String)
}

This interface represents a notification system.

Create Concrete Implementations

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

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

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

Each class has its own behavior but follows the same contract.

Create the Factory

Kotlin
object NotificationFactory {

    fun create(type: String): Notification {
        return when (type.uppercase()) {
            "EMAIL" -> EmailNotification()
            "SMS" -> SmsNotification()
            "PUSH" -> PushNotification()
            else -> throw IllegalArgumentException("Unknown notification type")
        }
    }
}

This is the heart of the Factory Pattern.

The factory:

  • Knows which object to create
  • Hides creation logic from the rest of the app

Use the Factory

Kotlin
val notification = NotificationFactory.create("EMAIL")
notification.send("Welcome to our platform!")

That’s it.

The calling code:

  • Does not know about concrete classes
  • Does not change if new notification types are added

This is how the Factory Pattern Simplify Complex Object Creation in Kotlin applications.

Why Kotlin Works So Well with Factory Pattern

Kotlin makes the Factory Pattern even cleaner because of:

  • object keyword for singletons
  • when expressions
  • Strong type safety
  • Concise syntax

Factories in Kotlin are:

  • Easy to read
  • Hard to misuse
  • Simple to test

This makes Kotlin a great fit for modern, scalable architecture.

Real-World Use Cases

You’ll see the Factory Pattern used in:

  • Database connection creation
  • Payment gateways
  • Logging frameworks
  • UI component generation
  • API client creation

Anywhere object creation depends on conditions, the Factory Pattern Simplify Complex Object Creation effectively.

Factory Pattern and Clean Architecture

From an architectural perspective, the Factory Pattern supports:

  • Loose coupling
  • Open/Closed Principle
  • Single Responsibility Principle

Your system becomes:

  • Easier to extend
  • Safer to modify
  • More readable for new developers

Common Mistakes to Avoid

Even with factories, mistakes happen.

Avoid:

  • Putting business logic inside the factory
  • Creating overly complex factories
  • Ignoring interfaces

A factory should only create objects, nothing more.

When Should You Use the Factory Pattern?

Use it when:

  • Object creation logic is complex
  • You expect future extensions
  • You want cleaner, testable code

Avoid it for:

  • Very small or one-off objects
  • Simple scripts

Conclusion

The Factory Pattern Simplify Complex Object Creation by separating object creation from object usage.

It keeps your code:

  • Clean
  • Flexible
  • Easy to maintain

In modern Kotlin applications, this pattern is not just useful. It’s often essential.

If you’re building scalable systems, learning and applying the Factory Pattern is a smart investment in code quality and long-term success.

Use Case Patterns

Mastering Use Case Patterns: A Practical Guide for Modern Software Design

Modern software design isn’t just about writing code that works; it’s about writing code that’s maintainable, scalable, and easy to understand. One way to achieve this is by using Use Case Patterns — a structured approach to modeling software functionality based on real-world user interactions. In this guide, we’ll break down everything you need to know about use case patterns, provide practical Kotlin examples, and show how to apply them effectively in your projects.

What Are Use Case Patterns?

A use case pattern is a reusable template that describes how a system should respond to specific user actions or events. Think of them as building blocks for designing your software’s functionality. Instead of starting from scratch each time, you can rely on patterns to standardize workflows, reduce errors, and speed up development.

For example, common use case patterns include:

  • Authentication — logging in and out
  • CRUD Operations — create, read, update, delete
  • Notification Handling — sending emails or push notifications

These patterns provide a blueprint for solving recurring problems, making your code more predictable and maintainable.

Why Use Use Case Patterns in Modern Software Design?

  1. Consistency: Patterns ensure that similar functionalities follow a consistent approach across your project.
  2. Reusability: Once you define a pattern, you can reuse it in multiple parts of your app without rewriting code.
  3. Clarity: Clear use case patterns make it easier for new developers to understand your system.
  4. Scalability: Patterns help design systems that can grow without becoming messy or unmanageable.

Core Principles of Use Case Patterns

To master use case patterns, keep these principles in mind:

  • Single Responsibility: Each pattern should handle one type of use case.
  • Clear Actors: Define who or what interacts with the system.
  • Explicit Steps: Document each step the system performs in response to an action.
  • Reusability: Design patterns so they can be applied in multiple scenarios.

Implementing Use Case Patterns in Kotlin

Kotlin is a modern, concise programming language that’s perfect for demonstrating use case patterns. Let’s go through a simple example: a user registration system.

Define the Use Case Interface

Start by creating an interface that represents the use case:

Kotlin
interface UseCase<in Input, out Output> {
    fun execute(input: Input): Output
}

Here’s what’s happening:

  • Input is the data the use case needs (e.g., user info).
  • Output is the result of executing the use case (e.g., success or error).
  • execute() is the method that contains the business logic.

Implement a Specific Use Case

Now, let’s implement a RegisterUserUseCase:

Kotlin
data class User(val username: String, val email: String, val password: String)

class RegisterUserUseCase : UseCase<User, Boolean> {
    override fun execute(input: User): Boolean {
        if (input.username.isEmpty() || input.email.isEmpty() || input.password.isEmpty()) {
            return false
        }
        println("User ${input.username} registered successfully!")
        return true
    }
}

Here,

  • The User data class holds user information.
  • RegisterUserUseCase implements the UseCase interface.
  • The execute method checks for valid input and prints a success message.
  • Returning true or false indicates whether the registration was successful.

Use the Use Case

Finally, use the pattern in your application:

Kotlin
fun main() {
    val registerUseCase = RegisterUserUseCase()
    val newUser = User("amol", "[email protected]", "password123")

    val isRegistered = registerUseCase.execute(newUser)
    println("Registration status: $isRegistered")
}

This simple example shows how use case patterns create a clear, reusable structure. You can now create other use cases, like LoginUserUseCase, following the same template.

Best Practices for Use Case Patterns

  1. Keep Use Cases Small: Avoid overloading a single use case with too many responsibilities.
  2. Focus on Business Logic: Use case patterns should contain only business logic — not UI or database code.
  3. Combine With Repositories: Use repositories or services for data access while keeping the use case focused.
  4. Document Clearly: Add descriptions for each use case to improve maintainability.

Advanced Tip: Chaining Use Cases

Sometimes, a single user action involves multiple steps. Kotlin’s flexibility allows chaining use cases:

Kotlin
class CompleteUserOnboardingUseCase(
    private val registerUserUseCase: RegisterUserUseCase,
    private val sendWelcomeEmailUseCase: SendWelcomeEmailUseCase
) : UseCase<User, Boolean> {
    override fun execute(input: User): Boolean {
        val registered = registerUserUseCase.execute(input)
        if (!registered) return false
        sendWelcomeEmailUseCase.execute(input)
        return true
    }
}

Here, the CompleteUserOnboardingUseCase combines registration and email notification, keeping each use case modular and reusable.

Conclusion

Mastering use case patterns is a game-changer for modern software design. They help you write cleaner, maintainable code that is easy to understand and scale. Using Kotlin, you can implement these patterns with minimal boilerplate, keeping your focus on business logic.

Start small, focus on clarity, and gradually build a library of reusable patterns. Before long, your software architecture will be robust, consistent, and much easier to maintain.

By embracing use case patterns, you not only improve your code today — you future-proof your projects for tomorrow.

Repository Pattern

How the Repository Pattern Makes Code Easier to Test, Maintain, and Scale

Software projects rarely stay small. Features grow, requirements change, and teams expand. When data access logic is tightly coupled with business logic, even a simple update can break multiple parts of the system.

This is where the Repository Pattern becomes extremely valuable.

In this blog, we’ll explain the Repository Pattern in a clear and beginner-friendly way using Kotlin examples. You’ll learn what it is, why it matters, and how it makes your code easier to test, maintain, and scale over time.

What Is the Repository Pattern?

The Repository Pattern is a design pattern that separates data access logic from business logic.

Instead of letting your services or view models talk directly to a database, API, or data source, all data operations are handled by a repository. Your business logic interacts only with the repository interface.

You can think of the repository as a middle layer that hides all the details of how data is stored or retrieved.

This separation leads to cleaner, safer, and more flexible code.

Why the Repository Pattern Is Important

Without the Repository Pattern, applications often suffer from:

  • Database queries scattered across the codebase
  • Business logic tightly tied to a specific database or framework
  • Difficult and slow unit testing
  • High risk when changing data sources

The Repository Pattern solves these problems by creating a single, consistent place for data access.

Core Structure of the Repository Pattern

A typical Repository Pattern implementation includes:

  1. A repository interface that defines allowed operations
  2. A repository implementation that handles actual data access
  3. Business logic that depends only on the interface

Let’s walk through a simple Kotlin example.

Repository Pattern in Kotlin

Step 1: Define a Data Model

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

This is a simple data class that represents a user in the system.

Step 2: Create the Repository Interface

Kotlin
interface UserRepository {
    fun getById(id: Int): User?
    fun getAll(): List<User>
    fun add(user: User)
}

This interface defines what the application can do with user data. It does not care how or where the data is stored.

Step 3: Implement the Repository

Kotlin
class UserRepositoryImpl(private val database: UserDatabase) : UserRepository {

    override fun getById(id: Int): User? {
        return database.users.find { it.id == id }
    }

    override fun getAll(): List<User> {
        return database.users
    }

    override fun add(user: User) {
        database.users.add(user)
    }
}

This class contains all the data access logic. Whether the data comes from Room, SQL, an API, or another source, the rest of the app does not need to know.

Step 4: Use the Repository in Business Logic

Kotlin
class UserService(
    private val userRepository: UserRepository
) {
    fun registerUser(user: User) {
        userRepository.add(user)
    }
}

The service depends on the repository interface, not the implementation. This design choice is key to flexibility and testability.

How the Repository Pattern Improves Testability

Testing becomes much easier with the Repository Pattern because dependencies can be replaced with fake or mock implementations.

Fake Repository for Testing

Kotlin
class FakeUserRepository : UserRepository {
    private val users = mutableListOf<User>()

    override fun getById(id: Int): User? {
        return users.find { it.id == id }
    }

    override fun getAll(): List<User> {
        return users
    }

    override fun add(user: User) {
        users.add(user)
    }
}

You can now test your service without a real database:

Kotlin
val repository = FakeUserRepository()
val service = UserService(repository)

service.registerUser(User(1, "Amol"))

This approach results in faster, more reliable tests and supports accurate, verifiable behavior.

How the Repository Pattern Improves Maintainability

As applications grow, maintainability becomes more important than short-term speed.

The Repository Pattern helps by:

  • Keeping data logic in one place
  • Reducing duplicated queries
  • Making code easier to read and reason about
  • Allowing safe refactoring

If you need to update how users are stored or retrieved, you only change the repository implementation.

How the Repository Pattern Helps with Scalability

Scalability is about more than performance. It’s also about adapting to future changes.

With the Repository Pattern, you can:

  • Add caching inside the repository
  • Switch databases or APIs
  • Introduce pagination or background syncing

For example, you might later enhance this:

Kotlin
override fun getAll(): List<User> {
    return database.users
}

Without changing any business logic that depends on it.

Common Mistakes to Avoid

When using the Repository Pattern, avoid these pitfalls:

  • Putting business logic inside repositories
  • Exposing database-specific models directly
  • Adding unnecessary abstraction to very small projects

The Repository Pattern should simplify your code, not complicate it.

When Should You Use the Repository Pattern?

The Repository Pattern is a great choice when:

  • Your app has complex business rules
  • You expect data sources to evolve
  • You want clean unit tests
  • Your project is designed for long-term growth

For quick prototypes, it may be unnecessary. For production systems, it’s often worth the investment.

Conclusion

The Repository Pattern helps you write code that is easier to test, easier to maintain, and easier to scale.

By separating data access from business logic, you create a cleaner architecture that supports growth and change.

When implemented correctly in Kotlin, the Repository Pattern leads to reliable, readable, and future-proof applications that developers can trust.

@ApplicationContext

How @ApplicationContext Works in Jetpack Compose with Hilt : A Practical, Clean-Architecture Guide (With Runtime Explanation)

If you’ve ever used Hilt in a Jetpack Compose app, you’ve probably written code like this:

Kotlin
class MyRepository @Inject constructor(
    @ApplicationContext private val context: Context
)

And then paused for a second and thought:

“Okay… but where is this @ApplicationContext coming from?”
 
“Who creates it?”
 
“And how does Hilt magically inject it at runtime?”

This article answers those questions deeply and practically — without buzzwords, without hand-waving, and without unsafe patterns.

We’ll cover:

  • Why ViewModels should not receive Context
  • How Hilt resolves @ApplicationContext at runtime
  • The exact dependency flow from Compose → ViewModel → Repository
  • Why this approach is clean, safe, and testable
  • What code Hilt generates behind the scenes (conceptually)
  • Common mistakes and how to avoid them

Why Passing Context to ViewModel Is a Bad Idea

Let’s start with the mistake most Android developers make at least once:

Kotlin
class MyViewModel(private val context: Context) : ViewModel()

This looks harmless — until it isn’t.

Jetpack ViewModels are designed to outlive UI components like Activities and Fragments. An Activity context, however, is tied to the Activity lifecycle.

If a ViewModel holds an Activity context:

  • The Activity cannot be garbage collected
  • Memory leaks occur
  • Configuration changes become dangerous
  • Testing becomes harder

This is why Android’s architecture guidelines are very clear:

ViewModels should not hold a Context.

But what if you need access to:

  • SharedPreferences
  • DataStore
  • ConnectivityManager
  • Location services
  • File system APIs

You do need a Context — just not in the ViewModel. This is where repositories and Application context come in.

The Clean Architecture Rule

Here’s the mental model that solves this cleanly:

LayerResponsibilityContext Allowed
UI (Compose)Rendering, user inputYes (UI-only)
ViewModelState & business logicNo
RepositoryData & system accessYes
ApplicationApp lifecycleYes

So the rule is simple:
 If something needs a Context, it belongs below the ViewModel layer.

The Correct Dependency Flow

In a modern Compose app using Hilt, the flow looks like this:

Kotlin
Compose Screen

ViewModel (no Context)

Repository (Application Context)

Android System Services

The ViewModel never touches Context.
 The Repository owns it.
 The Application provides it.

So… Where Does @ApplicationContext Come From?

This is the part that feels like magic — but isn’t.

@HiltAndroidApp Creates the Root Component

Kotlin
@HiltAndroidApp
class MyApp : Application()

When you add this annotation, Hilt:

  • Generates a base class for your Application
  • Creates a singleton Application-level component
  • Stores the Application instance inside it

At runtime, Android creates your Application before anything else.

That Application instance is a Context.

Hilt Has a Built-In Context Provider

Inside Hilt’s internal codebase (not yours), there is a binding equivalent to:

Kotlin
@Provides
@Singleton
@ApplicationContext
fun provideApplicationContext(app: Application): Context = app

You never write this.
 You never import it.
 But it exists and is always available once @HiltAndroidApp is present.

So when Hilt sees:

Kotlin
@ApplicationContext Context

It knows exactly what to inject:
 ➡ the Application instance

Repository Requests the Context

Kotlin
class UserRepository @Inject constructor(
    @ApplicationContext private val context: Context
)

At compile time:

  • Hilt validates that a binding exists
  • It generates a factory class for MyRepository

Conceptually, the generated code looks like:

Kotlin
class MyRepository_Factory(
    private val contextProvider: Provider<Context>
) {
    fun get(): MyRepository {
        return MyRepository(contextProvider.get())
    }
}

At runtime:

  • contextProvider.get() returns the Application
  • The repository receives a safe, long-lived context

ViewModel Receives the Repository

Kotlin
@HiltViewModel
class UserViewModel @Inject constructor(
    private val repository: UserRepository
) : ViewModel()

The ViewModel:

  • Has no idea where the context comes from
  • Has no Android dependency
  • Is fully testable with fake repositories

Compose retrieves it like this:

Kotlin
val viewModel = hiltViewModel<UserViewModel>()

Hilt handles everything else.

A Real Example: SharedPreferences

Repository:

Kotlin
class UserPreferencesRepository @Inject constructor(
    @ApplicationContext context: Context
) {
    private val prefs =
        context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)

    fun saveUsername(name: String) {
        prefs.edit().putString("username", name).apply()
    }

    fun loadUsername(): String =
        prefs.getString("username", "Guest") ?: "Guest"
}

The ViewModel remains clean:

Kotlin
@HiltViewModel
class UserViewModel @Inject constructor(
    private val repository: UserPreferencesRepository
) : ViewModel() {

    val username = MutableStateFlow("")

    fun load() {
        username.value = repository.loadUsername()
    }
}

Notice what’s missing?

No Context in the ViewModel.
 That’s the whole point.

Why This Is Safe (And Recommended)

Let’s address the usual concerns.

Will this leak memory?
 No. Application context lives as long as the app process.

Will this break on rotation?
 No. ViewModels are lifecycle-aware; repositories aren’t tied to UI.

Is this officially recommended?
 Yes. This matches Google’s own Compose + Hilt samples.

Is this future-proof?
 Yes. This is the architecture Android is moving toward, not away from.

What If You Forget @HiltAndroidApp?

Your app will crash early with a clear error:

Kotlin
Hilt Activity must be attached to an @HiltAndroidApp Application

This happens because:

  • No ApplicationComponent is created
  • No Context binding exists
  • Dependency graph cannot be resolved

This is Hilt protecting you — not failing silently.

The One Rule to Remember

Context belongs to the data layer, not the state layer.

If you follow this rule:

  • Your architecture scales
  • Your code stays testable
  • Your app avoids subtle lifecycle bugs

Conclusion

@ApplicationContext isn’t magic.
 It’s a well-defined dependency provided by Hilt at the Application level, injected safely into the data layer, and kept far away from your UI state.

Once you understand this flow, Compose + Hilt stops feeling mysterious — and starts feeling predictable.

If this helped you, consider sharing it with the next developer who asks:
 “But where does the Context come from?”

back and forward navigation

How to Add Back and Forward Navigation in Android Studio IDE

Navigating through code efficiently is crucial for productive Android development. Android Studio provides powerful back and forward navigation features that help you jump between different code locations, making it easier to trace code flow, review changes, and return to previous working contexts. In this comprehensive guide, we’ll explore everything you need to know about implementing and using back and forward navigation in Android Studio.

Understanding Navigation in Android Studio

Before diving into the implementation, it’s important to understand what back and forward navigation means in the context of an IDE. Unlike web browsers where you navigate between pages, in Android Studio, navigation refers to moving between different cursor positions in your code files. Every time you jump to a method definition, search for a usage, or click on a reference, Android Studio records that location in your navigation history.

Default Navigation Shortcuts

Android Studio comes with built-in keyboard shortcuts for back and forward navigation that work out of the box. These shortcuts vary depending on your operating system.

For Windows and Linux:

  • Navigate Back: Ctrl + Alt + Left Arrow
  • Navigate Forward: Ctrl + Alt + Right Arrow

For macOS:

  • Navigate Back: Cmd + [ or Cmd + Alt + Left Arrow
  • Navigate Forward: Cmd + ] or Cmd + Alt + Right Arrow

These shortcuts allow you to quickly move through your navigation history without taking your hands off the keyboard, significantly improving your coding workflow.

Using the Navigation Toolbar

If you prefer using the mouse or want visual confirmation of navigation actions, Android Studio provides toolbar buttons for back and forward navigation.

The navigation buttons are located in the main toolbar, typically near the top-left of the IDE window. They appear as left and right arrows, similar to browser navigation buttons. When you hover over these buttons, tooltips appear showing the keyboard shortcuts and the destination file or location.

To enable or customize the toolbar, go to View > Appearance > Toolbar to ensure the main toolbar is visible. 

If you don’t see the navigation arrows, you may need to customize your toolbar layout.

How Navigation History Works

Understanding how Android Studio tracks your navigation history helps you use these features more effectively. The IDE maintains a stack of cursor positions that gets updated when you perform certain actions.

Actions that add to navigation history include:

  • Jumping to a method or class definition using Ctrl + Click or Cmd + Click
  • Using “Go to Declaration” with Ctrl + B or Cmd + B
  • Finding usages with Alt + F7 or Cmd + F7
  • Navigating to a line number with Ctrl + G or Cmd + L
  • Using “Go to Class,” “Go to File,” or “Go to Symbol” navigation
  • Clicking on items in search results, find usages panels, or the structure view
  • Navigating through bookmarks

Actions that typically don’t add to navigation history:

  • Simple cursor movements with arrow keys
  • Scrolling through a file
  • Typing or editing code
  • Moving within the same visible screen area

This intelligent tracking ensures your navigation history remains useful and doesn’t get cluttered with every minor cursor movement.

Customizing Navigation Shortcuts

If the default shortcuts don’t suit your workflow or conflict with other tools, you can customize them to your preference.

To customize navigation shortcuts, navigate to File > Settings on Windows/Linux or Android Studio > Preferences on macOS. Then follow these steps:

First, expand the Keymap section in the left sidebar. You’ll see a search box at the top of the keymap panel. Type “navigate / navigate back” to find the back navigation action, and “navigate / navigate forward” for the forward navigation action.

Right-click on “Back” under the Navigation category and you’ll see options to add keyboard shortcuts, mouse shortcuts, or abbreviations. Select “Add Keyboard Shortcut” and press your desired key combination. 

Android Studio will warn you if the shortcut is already assigned to another action, allowing you to resolve conflicts.

Repeat the same process for “Forward” navigation. Once you’ve set your preferred shortcuts, click “Apply” and “OK” to save your changes.

Advanced Navigation Techniques

Beyond basic back and forward navigation, Android Studio offers several advanced navigation features that complement these basic functions.

Recent Files Navigation: Press Ctrl + E on Windows/Linux or Cmd + E on macOS to open a popup showing recently opened files. This provides a quick way to jump to files you’ve worked on recently without going through the full navigation history.

Recent Locations: Press Ctrl + Shift + E on Windows/Linux or Cmd + Shift + E on macOS to see recent cursor locations with code context. This shows you snippets of code from locations you’ve recently visited, making it easier to find the exact spot you’re looking for.

Bookmarks: Set bookmarks at important locations in your code with F11 to create an anonymous bookmark or Ctrl + F11 or Cmd + F11 to create a numbered bookmark. 

Navigate between bookmarks using Shift + F11 to see all bookmarks, providing persistent navigation points beyond your session history.

Navigate to Last Edit Location: Press Ctrl + Shift + Backspace on Windows/Linux or Cmd + Shift + Backspace on macOS to jump directly to the last place you made an edit. This is particularly useful when you’ve navigated away to check something and want to return to where you were actively coding.

Navigation in Split Editor Mode

When working with multiple editor windows in split mode, navigation becomes even more powerful. Android Studio maintains separate navigation histories for each editor pane, allowing you to navigate independently in different views.

To split your editor, right-click on a file tab and select “Split Right” or “Split Down.” You can then navigate through different parts of your codebase simultaneously. The back and forward navigation shortcuts will apply to whichever editor pane currently has focus.

This is particularly useful when you’re comparing implementations, refactoring code across multiple files, or working on related components simultaneously.

Best Practices for Efficient Navigation

To make the most of Android Studio’s navigation features, consider adopting these best practices in your daily workflow.

1. Learn and use keyboard shortcuts consistently rather than relying on mouse clicks. Muscle memory for navigation shortcuts can dramatically speed up your coding process. The time invested in learning these shortcuts pays dividends in increased productivity.

2. Combine navigation with other IDE features like code search, find usages, and structure view to create efficient navigation patterns. For example, use “Find Usages” to see all references to a method, click on one to examine it, then use back navigation to return to your starting point.

3. Use bookmarks strategically for code locations you return to frequently within a project. This creates persistent reference points that survive beyond your current session.

4. Take advantage of the “Recent Locations” feature when you need to review multiple code sections you’ve recently visited. This provides more context than simple back navigation by showing code snippets.

5. When refactoring or reviewing code, use forward navigation to retrace your steps after going back. This helps you verify that you’ve addressed all necessary changes in a logical sequence.

Troubleshooting Navigation Issues

Sometimes navigation features may not work as expected. Here are common issues and their solutions.

Navigation shortcuts not working: First, check if the shortcuts are being intercepted by your operating system or other applications. On Windows, some keyboard manager utilities might capture these key combinations. On macOS, check System Preferences for conflicting shortcuts. Verify your keymap settings in Android Studio to ensure the shortcuts are properly configured.

Navigation history seems incomplete: Navigation history has limits to prevent memory issues. If you’ve navigated through many locations, older entries may be dropped. Additionally, closing and reopening files or projects may reset certain navigation states. Consider using bookmarks for locations you need to preserve across sessions.

Navigation buttons missing from toolbar: Go to View > Appearance > Toolbar to ensure the toolbar is visible. If the toolbar is visible but navigation buttons are missing, try customizing or resetting your toolbar layout or updating Android Studio to the latest version.

Navigation jumps to unexpected locations: This can happen if files have been modified externally or if you’re working with generated code that gets refreshed. Ensure your project is properly synchronized with File > Sync Project with Gradle Files and that you’re not editing generated files that get overwritten.

Navigation in Large Projects

In large Android projects with hundreds or thousands of files, efficient navigation becomes even more critical. The combination of back/forward navigation with Android Studio’s other navigation tools creates a powerful workflow.

Use the project structure view in conjunction with navigation history. When you need to explore a new area of the codebase, use “Go to Class” or “Go to File” to jump to relevant files, examine them, and then use back navigation to return to your working context.

Leverage the “Call Hierarchy” feature with Ctrl + Alt + H or Cmd + Alt + H to understand method call chains. Navigate through the hierarchy, then use back navigation to return to your starting point. This combination helps you trace execution flow in complex applications.

The “Type Hierarchy” feature, accessed with Ctrl + H or Cmd + H, works similarly for understanding class inheritance and implementations. Navigate through the hierarchy tree, and use navigation history to backtrack when needed.

Conclusion

Mastering back and forward navigation in Android Studio is essential for efficient Android development. These features, combined with the IDE’s other navigation capabilities, create a powerful toolkit for exploring codebases, understanding code flow, and maintaining productivity.

Start by memorizing the basic keyboard shortcuts for your operating system, then gradually incorporate advanced navigation techniques into your workflow. As these patterns become second nature, you’ll find yourself navigating code with confidence and speed, spending less time searching for code and more time writing it.

Remember that effective navigation is about developing habits and patterns that work for your specific coding style. Experiment with different combinations of navigation features, customize shortcuts to match your preferences, and build a navigation workflow that maximizes your productivity in Android Studio.

AlarmManager

Understanding AlarmManager, PendingIntent, BroadcastReceiver, and BootReceiver in Android

Android background execution is one of the most misunderstood parts of the platform, especially when alarms, notifications, and device reboot come into play.

Most tutorials show what code to write. Very few explain why that code works, what Android is doing internally, or why certain architectural rules are non-negotiable.

This article fills that gap.

We’ll walk through AlarmManager, PendingIntent, BroadcastReceiver, and BootReceiver using real, production-style code, explaining not just the syntax but the system behavior behind it.

This is not a copy of documentation.
 It’s an experience-driven explanation based on how Android actually behaves in real apps.

The Core Problem Android Is Solving

Android applications do not run continuously.

The OS is designed to:

  • Preserve battery
  • Free memory aggressively
  • Kill background processes at any time

Yet apps still need to:

  • Show notifications at specific times
  • Perform actions when the app is closed
  • Restore scheduled tasks after a device reboot

Android solves this using system-controlled execution, not app-controlled execution.

That single idea explains everything else in this article.

AlarmManager: Scheduling Time, Not Code

The Most Common Misconception

“AlarmManager runs my code at a specific time.”

It doesn’t.

AlarmManager has one job only:

At a specific time, notify the Android system that something needs to happen.

AlarmManager:

  • Does not execute methods
  • Does not keep your app alive
  • Does not know what your app does

It simply stores:

  • A trigger time
  • A PendingIntent (what the system should do later)

Scheduling an Alarm (MainActivity)

Let’s look at real code and follow the execution flow.

Java
public class MainActivity extends AppCompatActivity {

    private AlarmManager alarmManager;
    private PendingIntent pendingIntent;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
        findViewById(R.id.btnSetAlarm).setOnClickListener(v -> scheduleAlarm());
        findViewById(R.id.btnCancelAlarm).setOnClickListener(v -> cancelAlarm());
    }

Creating the Intent

This Intent does not execute anything.
 It simply describes what component should be triggered later.

Java
private void scheduleAlarm() {
    Intent intent = new Intent(this, AlarmReceiver.class);
    intent.setAction("com.softaai.alarmdemo.ALARM_ACTION");
    intent.putExtra("message", "Your scheduled reminder!");

PendingIntent: The Permission Slip for the System

Now comes the most important part.

Java
pendingIntent = PendingIntent.getBroadcast(
        this,
        0,
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
    );

A PendingIntent tells Android:

“You are allowed to perform this action on my app’s behalf later, even if my app process no longer exists.”

Why this matters:

  • Your app may be killed
  • Your activity may never run again
  • Your app may not be in memory

Without PendingIntent, alarms would be impossible in a battery-safe OS.

Scheduling with AlarmManager

Now we calculate when the alarm should fire.

Java
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND, 30);

long triggerTime = calendar.getTimeInMillis();

And hand everything to the system:

Java
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        alarmManager.setExactAndAllowWhileIdle(
            AlarmManager.RTC_WAKEUP,
            triggerTime,
            pendingIntent
        );
    } else {
        alarmManager.setExact(
            AlarmManager.RTC_WAKEUP,
            triggerTime,
            pendingIntent
        );
    }
}

Important:
 At this point, your app is no longer involved.
 AlarmManager stores the data and the system takes over.

What Actually Happens Internally

  1. AlarmManager stores the trigger time + PendingIntent
  2. Your app process can be killed at any moment
  3. When time arrives:
  • Android wakes up
  • Android fires the PendingIntent
  • Android decides how to re-enter your app

AlarmManager never runs your code.
 The system does.

BroadcastReceiver: The Entry Point Back Into Your App

When the alarm fires, Android needs a way to re-enter your app safely.

That entry point is a BroadcastReceiver.

Java
public class AlarmReceiver extends BroadcastReceiver {

    @Override
    public void onReceive(Context context, Intent intent) {
        String message = intent.getStringExtra("message");
        if (message == null) message = "Time's up!";
        NotificationHelper.showNotification(
            context,
            "Alarm",
            message
        );
    }
}

What Android Does Here

  • Creates your app process if needed
  • Instantiates the receiver
  • Calls onReceive()
  • Expects it to finish quickly

Important constraints:

  • Runs on the main thread
  • Must finish in ~10 seconds
  • Not meant for long work

If you need long processing, hand off to WorkManager or a service.

Why Alarms Disappear After Reboot

This is intentional behavior.

When a device reboots:

  • RAM is wiped
  • System services restart
  • All alarms are cleared

Android does this to avoid restoring stale or invalid schedules.

So if your app uses alarms, you must restore them manually.

BootReceiver: Restoring Alarms After Reboot

This is where BootReceiver comes in.

Java
public class BootReceiver extends BroadcastReceiver {

    @Override
    public void onReceive(Context context, Intent intent) {
        if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
            rescheduleAlarms(context);
        }
    }

Rescheduling the Alarm

Java
private void rescheduleAlarms(Context context) {
    AlarmManager alarmManager =
        (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);

    Intent alarmIntent = new Intent(context, AlarmReceiver.class);
    alarmIntent.setAction("com.softaai.alarmdemo.ALARM_ACTION");
    alarmIntent.putExtra("message", "Alarm restored after reboot");
    
    PendingIntent pendingIntent = PendingIntent.getBroadcast(
        context,
        0,
        alarmIntent,
        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
    );
    
    Calendar calendar = Calendar.getInstance();
    calendar.add(Calendar.MINUTE, 1);
    alarmManager.setExactAndAllowWhileIdle(
        AlarmManager.RTC_WAKEUP,
        calendar.getTimeInMillis(),
        pendingIntent
    );
}

A BootReceiver exists for one reason only:

Re-create state lost due to reboot.

Nothing more.

Why AlarmReceiver and BootReceiver Should Be Separate

1. Different Responsibilities

  • AlarmReceiver → time-based events
  • BootReceiver → system lifecycle events

Mixing them creates confusing and fragile code.

2. Different Security Requirements

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
XML
<receiver
    android:name=".BootReceiver"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED" />
    </intent-filter>
</receiver>

<receiver
    android:name=".AlarmReceiver"
    android:exported="false" />

Boot receivers must be exported.
 Alarm receivers should not be.

Combining them exposes alarm logic to other apps. That’s a security bug.

3. Boot Is a Fragile System State

During boot:

  • Network may be unavailable
  • Storage may not be ready
  • UI work is unsafe

Separating receivers prevents accidental crashes and ANRs.

Industry-Standard Architecture

Well-designed Android apps follow this pattern:

  • AlarmReceiver → reacts to alarms
  • BootReceiver → restores alarms
  • Shared logic → scheduler/helper classes

Receivers stay thin.
 Business logic stays reusable.

This is how production Android apps are built.

Key Takeaways

  • AlarmManager schedules time, not code
  • PendingIntent is a system-approved execution token
  • BroadcastReceiver is a system entry point, not a worker
  • Alarms are wiped on reboot by design
  • BootReceiver restores lost state
  • Combining receivers is unsafe and unmaintainable

FAQ 

Does AlarmManager run my code?

No. It only notifies the system, which decides when and how to re-enter your app.

Why is PendingIntent required?

It allows Android to safely execute actions even if your app process is dead.

Why do alarms disappear after reboot?

Android clears all alarms intentionally to avoid restoring invalid schedules.

Is BootReceiver mandatory for alarm apps?

Yes. Without it, alarms will silently stop after reboot.

Conclusion

Android background execution is not about forcing your app to stay alive.

It’s about cooperating with the system so your app runs only when it is allowed, necessary, and safe.

Once you understand that mindset, AlarmManager, PendingIntent, and BroadcastReceivers stop feeling magical — and start feeling predictable.

How to Fix Wrong Git Commits in Android Studio and Safely Remove Unwanted Remote Commits

How to Fix Wrong Git Commits in Android Studio and Safely Remove Unwanted Remote Commits

If you’ve ever opened GitHub and noticed the wrong name on your commits — or spotted a commit on your main branch that never should’ve been there. These are some of the most common (and stressful) Git problems developers run into, especially when working in teams or switching between multiple accounts.

The good news is this: Git gives you the tools to fix both issues cleanly. The bad news? Using the wrong command can make things worse if you don’t understand what’s really going on.

This guide walks you through:

  • Why Android Studio shows the wrong Git user
  • How to correctly change the Git author (without breaking anything)
  • How to fix commits that are already pushed
  • When to remove commits entirely vs safely reverting them
  • Best practices to avoid these problems in the future

Everything here is based on how Git actually works under the hood — not IDE myths.

Why Android Studio Shows the Wrong Git User

Let’s clear up the most common misunderstanding first:

Android Studio does not control your Git commit author. Git does.

Android Studio is just a client. When you click “Commit,” it asks Git two questions:

  • What is the author name?
  • What is the author email?

Git answers based on its configuration files. That’s it.

So changing your GitHub account inside Android Studio affects authentication (push and pull permissions), but it does not change the commit author. That’s why this issue keeps coming back.

How Git Decides Who You Are

Git identifies authors using two values:

  • user.name
  • user.email

These can exist at two levels:

  1. Global — applies to all repositories on your machine
  2. Local (project-specific) — applies only to the current repository

Git always prefers local settings over global ones.

The Correct Way to Change Git User in Android Studio (Recommended)

If you work on multiple projects or use more than one GitHub account, this is the safest approach.

Step 1: Open the Terminal in Android Studio

Open your project, then click Terminal at the bottom.

Step 2: Set the Git user for this project only

Bash
git config user.name "Your Correct Name"<br>git config user.email "[email protected]"

Step 3: Verify

Bash
git config user.name<br>git config user.email

From this point forward, all new commits in this project will use the correct author.

Changing Git User Globally (Use With Caution)

If every repository on your machine is using the wrong user, update the global config:

Bash
git config --global user.name "Your Name"<br>git config --global user.email "[email protected]"

Verify with:

Bash
git config --global --lista

This affects all Git repositories on your system.

Why Switching GitHub Accounts Doesn’t Fix Commit Names

Android Studio’s GitHub settings only control:

  • Authentication
  • Push and pull permissions

They do not control commit authorship. That’s why you can push to one account while commits show another name entirely.

SSH Keys: Why Pushes Go to the Wrong Account

If you’re using SSH, GitHub identifies you by your SSH key, not your username.

Check which account your machine is using:

If GitHub responds with the wrong username, your SSH key is attached to the wrong account.

The Correct Fix

  • Generate a new SSH key
  • Add it to the correct GitHub account
  • Configure ~/.ssh/config to explicitly use that key

This ensures commits and permissions align correctly.

Why Existing Commits Don’t Update Automatically

Changing Git config only affects future commits.

Once a commit exists:

  • Its author is permanent
  • It cannot be changed unless history is rewritten

That’s intentional. Git values integrity over convenience.

How to Fix the Author of the Last Commit

If the most recent commit has the wrong author:

Bash
git commit --amend --author="Your Name <[email protected]>"<br>git push --force-with-lease

Only do this if rewriting history is acceptable.

Fixing Multiple Commits With the Wrong Author

Use interactive rebase:

Bash
git rebase -i HEAD~N

Change pick to edit for the commits you want to fix, then run:

Bash
git commit --amend --author="Your Name <[email protected]>"<br>git rebase --continue

Finish with:

Bash
git push --force-with-lease

Removing an Unwanted Commit From a Remote Repository

This is where many developers panic. The right solution depends on who else is affected.

Option 1: Hard Reset (Deletes History)

Use this only if:

  • It’s your personal branch, or
  • The commit contains sensitive data

Steps

Bash
git log --oneline<br>git reset --hard <last-good-commit><br>git push origin <branch> --force-with-lease

Everyone else must reset their local branch afterward.

Option 2: Revert (Safest for Teams)

If the branch is shared or protected, always revert.

Bash
git revert <bad-commit-hash><br>git push origin <branch>

This creates a new commit that undoes the change without rewriting history.

When to Use Reset vs Revert

SituationRecommended
Secrets pushedReset + force push
Mistake on mainRevert
Cleaning your feature branchReset
Team already pulledRevert

Removing Multiple Commits Cleanly (Interactive Rebase)

For mixed good and bad commits:

Bash
git rebase -i HEAD~5

Change pick to drop for unwanted commits, then:

Bash
git push --force-with-lease

Always create a backup branch first:

git branch backup-before-cleanup

Common Mistakes to Avoid

  • Force-pushing without warning your team
  • Using --force instead of --force-with-lease
  • Rewriting history on protected branches
  • Forgetting to back up before rebasing

If something goes wrong, git reflog can usually save you.

Best Practices to Prevent These Issues

  • Always verify Git user before starting a project
  • Prefer project-level Git config
  • Use separate SSH keys for different accounts
  • Protect main branches with PR rules
  • Never force-push without coordination
  • Keep commit emails matching your GitHub account

Conclusion

Android Studio often gets blamed for Git problems it doesn’t actually control. Once you understand that Git owns identity and history, everything becomes easier to reason about — and fix.

By setting the correct Git user, managing SSH keys properly, and choosing the right strategy for removing commits, you can keep your repositories clean without disrupting your team.

If you treat Git history with intention instead of panic, it becomes a powerful tool instead of a constant source of stress.

error: Content is protected !!