DEV Community

ReClaim
ReClaim

Posted on AI-assisted

Five Android permissions, five different checks: building a setup gate that re-checks on resume

We're building ReClaim, a minimalist Android launcher that's on Google Play. To do its job it needs five things from the user: to be the default Home app, to post notifications, Usage access, an accessibility service for system-wide blocking, and an exemption from battery optimization.

That sounds like one "permissions" problem. It isn't. Each of the five is granted in a different place, and each is checked through a different API. Only one of them is a normal runtime permission. This post walks through the screen that asks for all five: how it checks each one, why it re-checks every time the app comes back to the front, and why it never traps you.

Stack: Kotlin, Jetpack Compose, Hilt, minSdk 26, targetSdk 36. Snippets are trimmed from the real code; // ... marks cuts. Our first post on building the launcher touched on the Home role and Usage access in passing. This one is about the whole gate.

One snapshot, five booleans

Everything the gate knows fits in one data class:

data class SetupStatus(
    val isDefaultLauncher: Boolean = false,
    val homeRoleStale: Boolean = false,
    val notificationsGranted: Boolean = true,
    val usageAccessGranted: Boolean = false,
    val accessibilityEnabled: Boolean = false,
    val batteryOptimizationExempt: Boolean = false,
) {
    val allGranted: Boolean
        get() = (isDefaultLauncher || homeRoleStale) && notificationsGranted && usageAccessGranted &&
            accessibilityEnabled && batteryOptimizationExempt
}
Enter fullscreen mode Exit fullscreen mode

Two defaults are worth a look. notificationsGranted starts as true because the notification permission doesn't exist before Android 13, so on older phones there's nothing to ask for. And homeRoleStale counts as "good enough" for the launcher check. More on that below.

The snapshot is a plain value. It's rebuilt from scratch on every check, not patched field by field. That keeps the screen honest: what it shows is what Android said a moment ago, not what we remember asking for.

Five checks, five APIs

Here's the function that fills the snapshot:

fun refresh(context: Context) {
    val notificationsGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
            PackageManager.PERMISSION_GRANTED
    } else {
        true
    }
    val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
    val isDefaultLauncher = LauncherRoleUtil.isDefaultLauncher(context)
    val homeRoleStale = !isDefaultLauncher && LauncherRoleUtil.holdsHomeRole(context)
    // ...
    _status.value = SetupStatus(
        isDefaultLauncher = isDefaultLauncher,
        homeRoleStale = homeRoleStale,
        notificationsGranted = notificationsGranted,
        usageAccessGranted = usageStatsSource.hasPermission(),
        accessibilityEnabled = AccessibilityUtil.isBlockServiceEnabled(context),
        batteryOptimizationExempt = powerManager.isIgnoringBatteryOptimizations(context.packageName),
    )
    _hasChecked.value = true
}
Enter fullscreen mode Exit fullscreen mode

Five lines, five different subsystems. Let's go through them.

1. Notifications: the only real runtime permission

POST_NOTIFICATIONS is a runtime permission on Android 13 and higher. The check is the standard ContextCompat.checkSelfPermission(), and the request uses the AndroidX RequestPermission contract, which is what the runtime permissions guide recommends:

val requestNotifications = rememberLauncherForActivityResult(
    ActivityResultContracts.RequestPermission(),
) { viewModel.refresh(context) }
Enter fullscreen mode Exit fullscreen mode

The callback ignores the boolean it's handed and calls refresh() instead. The gate only ever reads its state from the system, so there's one source of truth.

There's a catch the same guide spells out: if the user taps Deny more than once, the system treats that as "don't ask again" and stops showing the dialog. After that, our "Enable" button calls launch(), the callback fires right away, and nothing visible happens. The gate doesn't handle that case yet. It's on the list below.

2. Usage access: an app op, not a permission

PACKAGE_USAGE_STATS is declared in the manifest, but declaring it does nothing on its own. The UsageStatsManager reference says the user "still needs to grant permission through the Settings application", via Settings.ACTION_USAGE_ACCESS_SETTINGS. You can't check it with checkSelfPermission(). You ask AppOpsManager:

fun hasPermission(): Boolean {
    val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        appOps.unsafeCheckOpNoThrow(
            AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName,
        )
    } else {
        @Suppress("DEPRECATION")
        appOps.checkOpNoThrow(
            AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName,
        )
    }
    return mode == AppOpsManager.MODE_ALLOWED
}
Enter fullscreen mode Exit fullscreen mode

Two details from the AppOpsManager reference are worth knowing. First, unsafeCheckOpNoThrow is now marked deprecated in API level 36, and the reference points back to checkOpNoThrow. We target 36, so this branch has a deprecation warning waiting for it. Second, there's a mode besides allowed and ignored: MODE_DEFAULT, which means "use the default security check". The reference says callers "must explicitly check for it and deal with it". We treat anything that isn't MODE_ALLOWED as not granted. That's the cautious reading, but it means a device that reports MODE_DEFAULT with the permission actually granted would keep showing the row.

3. System-wide blocking: a string in secure settings

Accessibility services are switched on in Accessibility settings, never through a dialog in your app. To see whether ours is on, we read the list of enabled services from secure settings and look for our component:

fun isBlockServiceEnabled(context: Context): Boolean {
    val expected = ComponentName(context, BlockAccessibilityService::class.java).flattenToString()
    val enabled = Settings.Secure.getString(
        context.contentResolver,
        Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
    ) ?: return false
    val splitter = TextUtils.SimpleStringSplitter(':')
    splitter.setString(enabled)
    for (component in splitter) {
        if (component.equals(expected, ignoreCase = true)) return true
    }
    return false
}
Enter fullscreen mode Exit fullscreen mode

ENABLED_ACCESSIBILITY_SERVICES is documented only as the "List of the enabled accessibility providers". In practice it's a colon-separated list of flattened component names, which is why we split on :. It answers "did the user switch it on?" It doesn't tell you whether the service is bound and running right now. For a setup row, the first question is the right one.

This row also never jumps straight to Settings. The button opens our own disclosure dialog first. Google Play's AccessibilityService policy names launchers among the apps that aren't accessibility tools. Apps like that need a prominent disclosure inside the app that describes the data accessed, explains how it's used, and requires an affirmative user action. So the dialog states plainly that ReClaim isn't an accessibility tool and what the service reads, and only "Continue to Settings" opens ACTION_ACCESSIBILITY_SETTINGS. All three places in the app that can send you to that screen go through the same dialog, so there's no path that skips it.

4. Battery: an allowlist, and a policy question

PowerManager.isIgnoringBatteryOptimizations() tells you whether your package is on the device's power allowlist. Asking to be added is where you need to read the docs carefully:

fun batteryOptimizationIntent(context: Context): Intent =
    Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:${context.packageName}"))
Enter fullscreen mode Exit fullscreen mode

That action shows a one-tap system "Allow" dialog, and it needs the REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission in the manifest. The Settings reference is blunt about it: "most applications should not use this". The Doze and App Standby guide says most apps should send users to ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS instead. It also notes that Google Play policies prohibit asking for the direct exemption "unless the core function of the app is adversely affected", and it has a table of acceptable use cases.

Our reasoning is in the code comment: blocking depends on a background process, and Doze or App Standby restricting it can stop blocking without any visible error. If you're copying this pattern, read that table first. The settings-list intent is the safe default.

5. Default launcher: the role and the real answer

The Home row is the one we covered in the earlier post, so briefly: RoleManager can tell you whether you hold ROLE_HOME, but we answer "is ReClaim the launcher?" by resolving the actual Home intent. The two can disagree. Android can drop its saved Home choice while the role still says we hold it, and then the system's "Select a Home app" chooser keeps coming back.

What's new since that post is how the gate reacts. When the role is held but Home doesn't resolve to us, the row's button changes from "Enable" to "Fix" and the description explains what's going on. The role request intent is only built when there's a dialog to show:

onClick = {
    val roleIntent = LauncherRoleUtil.requestRoleIntent(context)
    when {
        roleIntent != null -> requestHomeRole.launch(roleIntent)
        status.homeRoleStale -> showHomeRoleRepair = true
        // No role flow at all (API < 29): the plain picker is the whole story.
        else -> context.startActivity(LauncherRoleUtil.homeSettingsIntent())
    }
},
Enter fullscreen mode Exit fullscreen mode

requestRoleIntent() returns null once we hold the role, because the system would finish the request without drawing anything. In the stale case we show a short dialog instead. It tells you to open Home settings and tap ReClaim even though it already looks selected, which re-applies the choice.

The same mismatch shaped one more helper. When ReClaim sends you home on purpose, for example when you leave a reminder, it scopes the Home intent to its own package while it holds the role:

fun homeIntent(context: Context): Intent {
    val intent = Intent(Intent.ACTION_MAIN)
        .addCategory(Intent.CATEGORY_HOME)
        .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    if (!holdsHomeRole(context)) return intent
    // ... logs a warning if Home currently resolves somewhere else
    intent.setPackage(context.packageName)
    return intent
}
Enter fullscreen mode Exit fullscreen mode

With only one possible match, the chooser can't appear. If we don't hold the role, the intent stays unscoped, so you land in whatever Home app you actually picked. That's also why allGranted accepts homeRoleStale: the launcher works, and the "Fix" row is there to explain the leftover prompts, not to block you.

Re-check on resume, not on result

Four of the five rows send you out of the app: to Accessibility settings, Usage access, Home settings, or a system dialog. Some of those return a result and some don't. startActivity() into Settings gives you nothing back. So the gate doesn't rely on results. It re-checks every time the activity resumes:

val setupViewModel: SetupViewModel = hiltViewModel()
val showSetupGate by setupViewModel.showGate.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { setupViewModel.refresh(context) }
DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, event ->
        if (event == Lifecycle.Event.ON_RESUME) setupViewModel.refresh(context)
    }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Enter fullscreen mode Exit fullscreen mode

The activity lifecycle guide is the reason this works. When the user comes back from another activity, the system calls onResume again, and lifecycle-aware components get ON_RESUME. Flip the switch in Settings, press Back, and the row turns into a check mark without you touching anything. All five checks are cheap local lookups, so running them on every resume costs very little.

A gate you can always walk past

Here's what decides whether the gate shows at all:

val showGate: StateFlow<Boolean> = combine(_status, _dismissed, _hasChecked) { status, dismissed, checked ->
    checked && !status.allGranted && !dismissed
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
Enter fullscreen mode Exit fullscreen mode

Three small choices are packed into that line.

  • checked first. Before the first refresh(), the snapshot is all defaults, which mostly read "not granted". Without _hasChecked, a fully set-up phone would flash the gate for a frame on every cold start.
  • Dismissal lasts for the session. The gate's main button is "Continue anyway", and it's always there. Tapping it sets _dismissed, which lives in the ViewModel. It isn't saved anywhere, so the gate comes back on the next cold start if anything is still missing. The runtime permissions guide says not to block the user and to let them cancel any educational flow, and that matches how we want a launcher to behave anyway. It has to work fully even if you skip every row.
  • An overlay, not a destination. The gate is drawn on top of the nav host rather than being a route in it. You can't land on it with a back press or navigate around it. It shows first, then the first-run tutorial, then the launcher.

What's still open

Writing this up turned up a few gaps, and we'd rather list them than pretend the gate is finished:

  • After two denials, the notifications row is a dead button. It should send you to the app's notification settings instead of calling launch() again.
  • The Settings reference warns that some settings actions may have no matching activity on some devices. Our startActivity() calls into Settings from the gate aren't wrapped yet.
  • Two settings screens show whether system-wide blocking is on, but they don't re-check on resume the way the gate does. One of them can show a stale value after you come back from Settings.
  • MODE_DEFAULT from AppOps is treated as "not granted", and unsafeCheckOpNoThrow needs replacing now that it's deprecated.

What we'd tell another team

  • List every special access your app needs and write down which API checks each one. "Permissions" is at least four different systems on Android.
  • Rebuild the whole status on ON_RESUME. Don't trust activity results for things granted in Settings.
  • Read state only from the system, even in the callback that just told you the answer.
  • Gate on "have we checked yet" so a set-up phone never flashes the gate.
  • Always offer a way past it. Bring it back next launch, not every screen.
  • For the accessibility service, write the disclosure before the service. For the battery exemption, read the acceptable-use table before you add the direct dialog.

If you'd like the user-side version of the Home step, our guide on how to change the default launcher on Android walks through it screen by screen. ReClaim is on Google Play if you want to see the gate on your own phone.

This post was drafted with AI assistance and reviewed by the ReClaim team.

Top comments (0)