DEV Community

Tal Aizikov
Tal Aizikov

Posted on AI-assisted

Building a minimal Android launcher in Kotlin: lessons from ReClaim

A launcher sounds like a big piece of software. It's the first thing you see when you unlock your phone and the thing you return to every time you press Home. But on Android, a launcher is "just" an app with one particular intent filter, plus a lot of small decisions about what to show and when.

We're building ReClaim, a minimalist Android launcher meant to make productive apps instant and distracting apps intentional. This post walks through the platform pieces it uses: how an app becomes the Home screen, how it lists and launches other apps, how it reads usage data, and how it enforces app blocks outside its own UI. Along the way we cover a few Android quirks we ran into.

For context, the app is written in Kotlin with Jetpack Compose and Material 3. It uses an MVVM structure with Hilt for dependency injection, Room for blocks and schedules, and DataStore for preferences. It targets minSdk 26 and targetSdk 36. The snippets below are trimmed versions of the real code.

1. Becoming a Home app

Android picks a Home screen by resolving an intent with ACTION_MAIN and CATEGORY_HOME. Any activity that declares that category is a candidate:

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTask"
    android:clearTaskOnLaunch="true"
    android:stateNotNeeded="true"
    android:excludeFromRecents="true"
    android:windowSoftInputMode="adjustNothing">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.HOME" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>
Enter fullscreen mode Exit fullscreen mode

The attributes matter as much as the filter:

  • singleTask means there is only ever one instance of the Home activity. Pressing Home while you're already on it doesn't create a new one.
  • clearTaskOnLaunch and stateNotNeeded tell the system the Home task can be reset and doesn't need saved state restored. That suits a screen users expect to look the same every time.
  • excludeFromRecents keeps the launcher out of the Recents list.
  • adjustNothing stops the keyboard from resizing the layout when search opens.

Because the activity is singleTask, pressing Home while ReClaim is already in front delivers the intent to onNewIntent() instead of recreating the activity. That's the hook for "go back to the main page and close whatever overlay is open":

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    setIntent(intent)
    rootViewModel.onHomePressed() // emits on a SharedFlow the Compose UI collects
}
Enter fullscreen mode Exit fullscreen mode

See Tasks and the back stack for how these launch modes behave.

2. Asking to be the default (and checking you really are)

Declaring the filter only makes you eligible. The user still has to choose you. On API 29+ the cleanest way to ask is RoleManager with ROLE_HOME, which shows a native system dialog. Below that, or when no dialog would appear, you fall back to the system Home-app picker via Settings.ACTION_HOME_SETTINGS:

fun requestRoleIntent(context: Context): Intent? {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null
    val rm = context.getSystemService(RoleManager::class.java) ?: return null
    if (!rm.isRoleAvailable(RoleManager.ROLE_HOME)) return null
    if (rm.isRoleHeld(RoleManager.ROLE_HOME)) return null // system would finish instantly, no dialog
    return rm.createRequestRoleIntent(RoleManager.ROLE_HOME)
}

fun homeSettingsIntent(): Intent =
    Intent(Settings.ACTION_HOME_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
Enter fullscreen mode Exit fullscreen mode

Here's the quirk. Android records the default launcher in two places: the ROLE_HOME holder and the PackageManager's preferred-activity entry for the Home intent. They can disagree. We hit a state where the preferred-activity entry had been dropped while the role stayed put. Home then resolved to the system chooser, and the user saw "Select a Home app" after a reboot or a low-memory kill, even though isRoleHeld() still returned true.

So to answer "does pressing Home actually open us?", we resolve the real Home intent instead of trusting the role:

fun isDefaultLauncher(context: Context): Boolean {
    val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
    val resolved = context.packageManager
        .resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
    return resolved?.activityInfo?.packageName == context.packageName
}

fun isHomeRoleStale(context: Context) =
    holdsHomeRole(context) && !isDefaultLauncher(context)
Enter fullscreen mode Exit fullscreen mode

When the state is stale, the role request finishes without showing anything, because the role is already held. What did repair it was re-selecting the launcher in the system Home-app picker, so the setup screen explains that step instead of showing a dead "Enable" button.

3. Listing and launching apps with LauncherApps

You can query PackageManager for CATEGORY_LAUNCHER activities. But LauncherApps is the API built for launchers. It returns launchable activities for a user profile and gives you callbacks when packages are added, removed or changed. Wrapping that in a callbackFlow gives the rest of the app a reactive list:

private val launcherApps = context.getSystemService(LauncherApps::class.java)
private val myUser = Process.myUserHandle()

fun currentApps(): List<RawApp> =
    launcherApps.getActivityList(null, myUser).map {
        RawApp(it.componentName.packageName, it.componentName.className, it.label.toString())
    }

fun observeApps(): Flow<List<RawApp>> = callbackFlow {
    val callback = object : LauncherApps.Callback() {
        override fun onPackageAdded(pkg: String?, user: UserHandle?) { trySend(currentApps()) }
        override fun onPackageRemoved(pkg: String?, user: UserHandle?) { trySend(currentApps()) }
        override fun onPackageChanged(pkg: String?, user: UserHandle?) { trySend(currentApps()) }
        override fun onPackagesAvailable(p: Array<out String>?, u: UserHandle?, r: Boolean) { trySend(currentApps()) }
        override fun onPackagesUnavailable(p: Array<out String>?, u: UserHandle?, r: Boolean) { trySend(currentApps()) }
    }
    launcherApps.registerCallback(callback, Handler(Looper.getMainLooper()))
    trySend(currentApps())
    awaitClose { launcherApps.unregisterCallback(callback) }
}.flowOn(Dispatchers.Default)
Enter fullscreen mode Exit fullscreen mode

Launching goes through launcherApps.startMainActivity(component, user, null, null). If that fails, it falls back to PackageManager.getLaunchIntentForPackage().

Two notes:

  • Package visibility. Since Android 11, apps only see a filtered set of other packages (package visibility). A launcher has to list everything, so ReClaim declares QUERY_ALL_PACKAGES. That's a core-functionality use under Google Play's permission policy, but it's a policy-reviewed permission, so only declare it if your app really is a launcher.
  • Uninstalling. The context menu starts Intent.ACTION_DELETE with a package: URI. On newer PackageInstaller UIs the confirmation dialog didn't appear at all unless the app also declared REQUEST_DELETE_PACKAGES. We also check FLAG_SYSTEM and FLAG_UPDATED_SYSTEM_APP first, so tapping Uninstall on a built-in app explains why it can't be removed instead of silently doing nothing.

A design decision worth mentioning: the Home screen shows a small set of user-chosen favorites with full-color icons. The full app drawer is a text-only alphabetical list with search and an A–Z index bar. The goal, straight from our product spec, is access to every installed app "without making distracting apps visually appealing." Leaving icons out of the drawer is the simplest friction we could add.

4. Reading usage data without fooling yourself

ReClaim uses UsageStatsManager for two things. One is suggesting apps to block together, based on the most-used apps over the last seven days. The other is showing per-app usage on its time-limit and reminder screens. It needs the special PACKAGE_USAGE_STATS access, which the user grants in Settings (Settings.ACTION_USAGE_ACCESS_SETTINGS). You check it through AppOpsManager, not the normal runtime-permission APIs:

fun hasUsageAccess(): Boolean {
    val mode = appOps.unsafeCheckOpNoThrow( // checkOpNoThrow below API 29
        AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName
    )
    return mode == AppOpsManager.MODE_ALLOWED
}
Enter fullscreen mode Exit fullscreen mode

For the "most used this week" list, queryUsageStats(INTERVAL_DAILY, start, end) summed by package is good enough.

For "how long have I used this app today", it wasn't. The aggregated stats are bucketed by day internally. A query that starts at local midnight could still include part of the previous day's bucket until it rolled over, which quietly breaks anything that resets at midnight. The fix was to replay the raw event stream from queryEvents() and sum foreground segments ourselves:

private fun foregroundMillis(pkg: String, start: Long, end: Long): Long {
    val events = usageStatsManager.queryEvents(start, end)
    val e = UsageEvents.Event()
    var active = 0; var segmentStart = -1L; var total = 0L
    while (events.hasNextEvent()) {
        events.getNextEvent(e)
        if (e.packageName != pkg) continue
        when (e.eventType) {
            UsageEvents.Event.MOVE_TO_FOREGROUND -> {
                if (active == 0) segmentStart = e.timeStamp.coerceIn(start, end)
                active++
            }
            UsageEvents.Event.MOVE_TO_BACKGROUND -> if (active > 0 && --active == 0) {
                total += e.timeStamp.coerceIn(start, end) - segmentStart
            }
        }
    }
    if (active > 0) total += end - segmentStart // still in the foreground
    return total
}
Enter fullscreen mode Exit fullscreen mode

The counter, instead of naive foreground/background pairing, handles an app briefly switching between its own activities without double-counting.

One more trap: the usage data only reliably reflects a session once it has ended. For live checks while an app is still open, ReClaim tracks the current session's start with SystemClock.elapsedRealtime() and adds it on top of the historical total.

5. Enforcing blocks outside the launcher

Blocking inside your own UI is easy: before launching, check whether the package is blocked and show a block screen instead. But users don't only open apps from the launcher. They also use Recents, notifications, links and other apps. For system-wide enforcement, ReClaim offers an optional AccessibilityService that watches which app comes to the foreground.

The service config subscribes to window events only:

<accessibility-service
    android:accessibilityEventTypes="typeWindowStateChanged|typeWindowsChanged"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:notificationTimeout="100"
    ... />
Enter fullscreen mode Exit fullscreen mode

The core of the handler is short:

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
    val e = event ?: return
    if (e.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
    val pkg = e.packageName?.toString() ?: return
    if (pkg == "com.android.systemui" || pkg == packageName) return // shade, QS, our own UI
    if (pkg !in blockedPackages) { lastPackage = pkg; return }

    // Debounce so one app switch doesn't stack several block screens.
    val now = SystemClock.elapsedRealtime()
    if (pkg == lastPackage && now - lastRedirectAt < 1_000L) return
    lastPackage = pkg; lastRedirectAt = now

    startActivity(Intent(this, BlockedActivity::class.java).apply {
        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
        putExtra(BlockedActivity.EXTRA_PACKAGE, pkg)
    })
}
Enter fullscreen mode Exit fullscreen mode

A few lessons from building this:

  • Ignore System UI. The notification shade, quick settings and the volume panel briefly take window focus without the user leaving their app. Treating them as an app switch caused wrong behavior downstream, so com.android.systemui is skipped entirely.
  • You can't kill another app. Android gives third-party apps no API to force-stop another app's process. Sending the user to a full-screen block (or Home) is the permitted way to do it.
  • Keep the blocked set in memory. blockedPackages comes from a Flow that merges temporary blocks from Room with scheduled blocks. It's re-evaluated whenever a block changes and once a minute so schedules switch on and off on time. The event handler only does a set lookup.
  • Blocks don't have an "undo". The repository deliberately has no public unblock(). A block simply expires. That's a product decision, not a technical one, but it shapes the data layer: expired rows are pruned lazily and the DAO's delete method exists only for cleanup.
  • Stay alive. Setup offers the one-tap REQUEST_IGNORE_BATTERY_OPTIMIZATIONS dialog so Doze/App Standby is less likely to restrict blocking in the background.

Accessibility APIs are sensitive, and Google Play treats non-accessibility uses of them as a policy-reviewed case. ReClaim shows its own disclosure dialog before sending the user to Accessibility settings. It says plainly that ReClaim isn't an accessibility tool, that only the foreground app's identity is checked, and that screen content is never read or sent off the device. The single exception is reading a picture-in-picture window's on-screen bounds so a video left playing after a block can be swiped away. If you build something similar, write that disclosure first. It forces you to keep the service small.

6. Small things nobody tells you

  • Pulling down notifications from a gesture has no public API. Launchers commonly call the hidden StatusBarManager.expandNotificationsPanel() via reflection, which needs the EXPAND_STATUS_BAR permission. Wrap it in runCatching. It's not guaranteed to exist.
  • Default shortcuts (Phone, Photos) are resolved from the system: ACTION_DIAL and CATEGORY_APP_GALLERY. Ignore results whose package is "android", because that's the resolver/chooser, not a real default.
  • Web search fires ACTION_WEB_SEARCH and falls back to a browser URL, so it still works on devices without a search app.

Wrapping up

The interesting part of a launcher isn't the grid of icons. It's the platform plumbing underneath: the HOME intent and its launch modes, the gap between ROLE_HOME and what Home actually resolves to, LauncherApps for the app list, usage events you have to replay yourself, and an accessibility service kept as small as possible.

ReClaim isn't on Google Play yet. If you want to try it when it launches, there's a waitlist at tryreclaim.io. If you're more interested in the user side of this, here's how we think about a minimal Android home screen setup.

Questions about any of the Android APIs above, or a launcher quirk you've hit yourself? Drop them in the comments.


This post was drafted with AI assistance and reviewed by the ReClaim team. Code excerpts are from the ReClaim app.

Top comments (1)

Collapse
 
beusebiu profile image
Eusebiu Balan •

The midnight bucket is a nasty one. I build an app where a lot resets at midnight, and the bugs I hated most had the same shape: nothing crashes, a number is just a bit wrong for an hour, and only for someone a few timezones away.

Replaying the raw events is more code, but at least the day boundary is yours and not the platform's.