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
}
}
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
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
}
))
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)
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
}
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)