DEV Community

Rexa
Rexa

Posted on

Blocking apps on iOS vs Android: I built the same feature twice and they have nothing in common

I spent a year building a focus app with an app blocker in it, which meant writing the feature once for Android and once for iOS. I expected two implementations of the same idea. What I got was two ideas that happen to produce the same screenshot.

The short version: on Android you build a blocker. On iOS you ask for one.

Android: you detect, you enforce

You poll UsageStatsManager from a foreground service to find out which app is in front, compare it against your blocked list, and launch your own translucent activity over the top.

val events = usm.queryEvents(lastQueryTime, now)
val event = UsageEvents.Event()
while (events.hasNextEvent()) {
    events.getNextEvent(event)
    if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
        currentForeground = event.packageName
    }
}
Enter fullscreen mode Exit fullscreen mode

event.packageName is a plain string. com.instagram.android. You know exactly what the user opened, you know when, and you could count it, log it, or send it somewhere. The platform trusts you not to.

Everything downstream is yours: your own app picker built off getInstalledApplications, your own lock screen with whatever countdown ring and copy you want, your own rules about grace periods and escape hatches.

The bill comes as operational work. A foreground service with a specialUse type and a justification string for the reviewer. A persistent notification you can't remove. Roughly a second of latency from the poll interval. Debouncing, because an app fires several foreground events while opening and your lock will strobe without a guard. And OEM battery managers that kill your service anyway.

iOS: you never find out anything

Here's the entire enforcement path on iOS:

sharedStore.shield.applications = selection.applicationTokens
Enter fullscreen mode Exit fullscreen mode

That's it. There is no service, no polling, no lock screen of yours, no detection of any kind. You hand ManagedSettingsStore a set of tokens and the OS does the shielding, at a level your process never sees.

The thing that took me longest to accept is what selection.applicationTokens actually contains. It's a set of ApplicationToken - opaque. Not a bundle ID. Not a name. Not an icon. You cannot decode it, log it, compare it to a list you hold, or find out which app the user picked. Your app blocks Instagram without ever learning that Instagram is installed.

Which means the picker can't be yours either:

FamilyActivityPicker(selection: Binding(
    get: { model.selection },
    set: { newSelection in
        model.selection = newSelection
        AppLockModule.sharedStore.shield.applications =
            newSelection.applicationTokens.isEmpty ? nil : newSelection.applicationTokens
    }
))
Enter fullscreen mode Exit fullscreen mode

FamilyActivityPicker is Apple's SwiftUI view. You present it and get tokens back. You can't style the rows, can't search it your way, can't show the user their blocked apps afterwards in your own design - because you don't know what they are. The best you can honestly show is a count.

Getting authorization is one call, and it's all-or-nothing:

try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
Enter fullscreen mode Exit fullscreen mode

The gotcha nobody mentions: persisting the tokens

Tokens are opaque but they are Codable, and you must store them yourself - and store them somewhere your extensions can reach, which means an App Group, not UserDefaults.standard:

private static let suiteName = "group.com.yourapp"

private static func saveSelection(_ selection: FamilyActivitySelection) {
    let data = try JSONEncoder().encode(selection)
    groupDefaults?.set(data, forKey: selectionKey)
}

private static func restoreSavedSelection() {
    guard let data = groupDefaults?.data(forKey: selectionKey) else { return }
    let selection = try JSONDecoder().decode(FamilyActivitySelection.self, from: data)
    sharedStore.shield.applications = selection.applicationTokens
}
Enter fullscreen mode Exit fullscreen mode

I shipped the standard-UserDefaults version first and had to write a migration when the extensions needed the same data. Start with the App Group.

Note the restore call has to run on init. Shields survive app termination, but your in-memory selection doesn't, and if you clear or re-apply without restoring first you silently unblock everything.

The trade, in one line each

Android iOS
Who enforces you the OS
What you learn the package name of everything opened nothing at all
The lock screen yours Apple's, unless you ship a shield extension
The picker yours Apple's, non-negotiable
Running cost foreground service, notification, battery none
Latency ~1s (poll interval) instant
Minimum version any, in practice iOS 16

What I actually think about it

Apple's design is more privacy-preserving than mine and it isn't close. On Android I chose UsageStatsManager over an AccessibilityService specifically to avoid reading the user's screen, and I still end up knowing every app they open. On iOS I couldn't collect that data if I wanted to, because the API never gives it to me. The guarantee is structural rather than a promise in a privacy policy.

The cost is that the iOS version is less mine. I can't design the moment that matters - the screen someone sees when they reach for the app out of habit - without shipping a separate shield extension, and even then within Apple's frame. On Android that screen is the product.

If you're building this: expect two codebases, not one abstraction with two backends. I tried the abstraction first. The shared interface ended up being three methods (requestPermissions, showPicker, setShieldActive) and every one of them means something different on each side.


This is from building Takeover, a focus app where the blocker is the point - both store links are there if you want to see how the two feel side by side. I'm the developer, so weigh the opinions accordingly.

Top comments (0)