The iOS Screen Time API in 2026: what Apple’s docs don’t tell you
I spent August and September 2026 shipping an app blocker on iOS. It uses the Screen Time frameworks (FamilyControls, DeviceActivity, ManagedSettings and ManagedSettingsUI) from React Native, through Expo SDK 57 and the react-native-device-activity library (0.6.1). Apple’s documentation tells you what each type is. It says very little about how they behave on a real phone, overnight, on iOS 26.
This is the list I wish I’d had. Behavior I saw on a device but can’t find documented is labeled observed; it may not hold on every iOS version. Items specific to the React Native library are labeled library; skip those if you write Swift directly.
- The moving parts
- Entitlements: one per bundle ID
- The App Group is the only channel
- Tokens are opaque, and the picker
- Metering usage with threshold events
- Schedules don’t start when you think
- stopMonitoring fires intervalDidEnd
- iOS 26 phantom thresholds
- A shield can’t open your app
- Keeping custom extensions through prebuild
- Xcode 26, the Simulator, and testing
- And on Android
- Checklist
- Quick answers
- Why I needed all this
1. The moving parts
“The Screen Time API” is four frameworks and, in practice, three app extensions:
- FamilyControls: authorization (
AuthorizationCenter, with.individualfor a self-control app) and theFamilyActivityPicker, which returns aFamilyActivitySelectionof app, category and web-domain tokens. - ManagedSettings: a
ManagedSettingsStoreyou put those tokens into to shield them. - DeviceActivity: schedules and usage-threshold events. The callbacks (
intervalDidStart,intervalDidEnd,eventDidReachThreshold, and the warning variants) are delivered to aDeviceActivityMonitorextension, not to your app. - ManagedSettingsUI: a
ShieldConfigurationDataSourceextension that styles the shield, and aShieldActionDelegateextension that handles its buttons.
The extensions are the point. My app is usually suspended or killed when a user runs out of time, so the monitor extension is what puts the shield back up. Design as if your app process will never be there when things happen.
2. Entitlements: one request per bundle ID
The Family Controls (Distribution) entitlement is approved per bundle ID. With three extensions, that’s four requests. Mine were:
com.honorfit.blocker(the app)com.honorfit.blocker.ShieldConfigurationcom.honorfit.blocker.ShieldActioncom.honorfit.blocker.ActivityMonitorExtension
Once they’re approved, enable the Family Controls (Distribution) capability on each App ID in the developer portal, then create one App Group and attach it to all four. Miss one and that target won’t provision. Approvals aren’t instant, so file all four requests on the first day, before you’ve written much code.
Every extension’s entitlements file ends up the same:
<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.honorfit.blocker</string>
</array>
On Expo/EAS, the build service only creates provisioning profiles for extensions it knows about. I declare each target, with its entitlements, under extra.eas.build.experimental.ios.appExtensions in app.json:
"appExtensions": [
{
"bundleIdentifier": "com.honorfit.blocker.ShieldConfiguration",
"targetName": "ShieldConfiguration",
"entitlements": {
"com.apple.developer.family-controls": true,
"com.apple.security.application-groups": ["group.com.honorfit.blocker"]
}
}
// ...same for ShieldAction and ActivityMonitorExtension
]
3. The App Group is the only channel
Your app and its extensions are separate processes that are almost never running at the same time. The one thing they share is the App Group container, usually as UserDefaults(suiteName:). Everything the extensions need (the stored selection, the shield text, what to do when a threshold fires) has to be written there ahead of time.
react-native-device-activity takes this all the way. The app writes a list of “actions” under a key like actions_for_{activity}_{callback}_{event}, and the monitor extension looks them up when the callback fires. A useful consequence: you can change what an already-running monitor does without restarting it. The extensions also call CFPreferencesAppSynchronize before reading, so they see the app’s latest writes:
let appGroup = Bundle.main.object(
forInfoDictionaryKey: "REACT_NATIVE_DEVICE_ACTIVITY_APP_GROUP") as? String
var userDefaults = UserDefaults(suiteName: appGroup)
// in each callback:
CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication)
if let actions = userDefaults?.array(forKey: triggeredBy) { /* run them */ }
4. Tokens are opaque, and the picker
The picker hands your app ApplicationTokens, ActivityCategoryTokens and WebDomainTokens, and nothing else. By design, the app can’t turn a token back into “Instagram”. What it can do is count them, so my blocklist screen shows “3 apps, 1 category”. That’s the whole privacy model, and it’s a good one: my app literally doesn’t know what you blocked.
The shield extension is different. There, the system passes you an Application for the app being shielded, and I use its localizedDisplayName in the shield text. The app itself only ever sees tokens.
Two practical notes:
- Categories stay categories. If the user picks “Social”, you get a category token, not the apps inside it, unless the selection was created with
includeEntireCategory. I keep itfalseso the counts I show are honest (“1 category”, not a count of apps I can’t name). - library react-native-device-activity’s persisted picker only preloads the saved selection (the checkmarks on reopen) if you pass the
includeEntireCategoryprop, with any value. Its native setter is what triggers the load. Without the prop, the picker opens empty every time even though the selection was saved.
<DeviceActivitySelectionSheetViewPersisted
familyActivitySelectionId="distracting-apps"
includeEntireCategory={false} // must be present, or nothing preloads
onSelectionChange={(e) => setCounts(e.nativeEvent)}
onDismissRequest={() => setPickerVisible(false)}
/>
library One more: the library’s blockSelection adds tokens to a persisted blocklist. After the user edits their selection, the removed apps stay shielded. I call resetBlocks() first and then re-block the current selection every time.
5. Metering usage with threshold events
HonorFit gives you a balance of minutes that only drains while a blocked app is on screen. DeviceActivity has no “tell me how long they’ve used it” call; it has threshold events that fire once cumulative usage of a selection reaches a duration. So I register one event per 15 seconds of usage, named in minutes: used-0.25, used-0.5, … up to the balance. Quarters are exact in binary floating point, so the names round-trip through Number(). Under the library, this is the plain Apple API:
let schedule = DeviceActivitySchedule(
intervalStart: DateComponents(hour: 9, minute: 45, second: 0), // see section 6
intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
repeats: false
)
let event = DeviceActivityEvent(
applications: selection.applicationTokens,
categories: selection.categoryTokens,
webDomains: selection.webDomainTokens,
threshold: DateComponents(minute: 0, second: 15),
includesPastActivity: false // iOS 17.4+
)
try DeviceActivityCenter().startMonitoring(
DeviceActivityName("honorfit-distracting-apps-1790000000000"),
during: schedule,
events: [DeviceActivityEvent.Name("used-0.25"): event] // ...one per step
)
includesPastActivity: false (the default) means usage from before the events were registered doesn’t count. That matters for the schedule trick in the next section.
When the final event fires, the monitor extension re-applies the shield and posts a “time’s up” notification. Each 15-second event updates one notification with a fixed identifier, so it replaces in place and works as a rough live countdown. interruptionLevel = .passive delivers it quietly to Notification Center with no banner.
library Don’t put underscores in event or activity names. The library persists fired events under events_{activity}_{callback}_{eventName} and parses them back by splitting on _. An event called used_7 comes back as used, and nothing ever matches. The symptom was a balance that never went down.
6. Schedules don’t start when you think
If your threshold events silently never fire, it’s probably this.
“Now” is already in the past
observed A non-repeating DeviceActivitySchedule is matched against date components, and the monitor runs the next interval that matches. If you pass the current wall-clock time as intervalStart, then by the time the framework evaluates it, that moment has passed. The next match is tomorrow, and today nothing fires: no thresholds, no callbacks, no error. The fix is to backdate the start so “now” is inside the interval:
const now = new Date();
const backdated = new Date(now.getTime() - 15 * 60_000);
const intervalStart =
backdated.getDate() === now.getDate()
? { hour: backdated.getHours(), minute: backdated.getMinutes(), second: backdated.getSeconds() }
: { hour: 0, minute: 0, second: 0 }; // don't wrap past midnight
await startMonitoring(activityName,
{ intervalStart, intervalEnd: { hour: 23, minute: 59, second: 59 }, repeats: false },
events);
Backdating can’t charge the user for earlier scrolling, because of includesPastActivity: false. It also keeps the interval above Apple’s minimum: schedules shorter than 15 minutes are rejected, which bites anyone starting a session just before midnight.
A schedule that contains “now” starts immediately
observed The flip side: a 00:00–23:59:59 schedule contains the current time, so it starts now, not at the next midnight. Two ways I found to book tomorrow:
- An already-expired schedule.
[00:00, T)where T is a minute before the current time of day. Today’s instance is over, so the framework books tomorrow’s. I use this to carry an unspent balance past midnight. repeats: truewith guarded actions. Today’s instance starts immediately and does nothing (every action is guarded, see the next section). Tomorrow’s does the real work.
observed Don’t use full year/month/day components in a schedule to target a specific date. Extension callbacks became unreliable when I tried, which matches reports on the Apple Developer Forums (thread 729841). Stick to hour/minute/second.
A caveat: these midnight behaviors only show up on a device, overnight. I’ve built guards around them but haven’t instrumented as many real midnights as I’d like, so treat this section as “what I saw”, not a spec.
7. stopMonitoring fires intervalDidEnd
observed Calling stopMonitoring, or calling startMonitoring with the name of a monitor that’s already running, makes iOS call intervalDidEnd on your extension as if the schedule had ended naturally. If your intervalDidEnd re-shields as a safety net (mine does, so a monitor can’t expire and leave apps unblocked), then every time the app stops a monitor, the extension briefly shields everything.
A user reported the symptom: Spotify paused every time they opened HonorFit. Stopping the monitor made the extension shield the selection for a moment, which suspended Spotify, before the app lifted the shields again.
Two fixes, both needed. First, delete the extension’s actions before stopping, so there’s nothing for it to run:
function stopActivities(names: string[]): void {
for (const name of names) RNDA.cleanUpAfterActivity(name); // clear actions first
RNDA.stopMonitoring(names); // then stop
}
Second, give every intervalDidEnd and intervalDidStart action a “not before” timestamp at the schedule’s real boundary, minus a minute of slack. The extension checks it before running anything:
// TypeScript: register the safety net
RNDA.configureActions({
activityName,
callbackName: 'intervalDidEnd',
actions: [{
type: 'blockSelection',
familyActivitySelectionId: 'distracting-apps',
neverTriggerBefore: new Date(intervalEndMs - 60_000),
}],
});
// Swift, in the DeviceActivityMonitor extension
if let neverTriggerBefore = neverTriggerBefore {
let now = Date().timeIntervalSince1970 * 1000
if now < neverTriggerBefore { return false } // skip this action
}
In plain Swift: store the genuine end time in the App Group and have intervalDidEnd bail out when called early.
8. iOS 26 phantom thresholds
On iOS 26.x, eventDidReachThreshold sometimes fires with no real usage at all: right after scheduling, or when a phone that sat locked for a while is picked up or plugged in. Apple has acknowledged this in developer forum threads (809410, 811305, 814559). It was reportedly fixed in a 26.5 beta, but people kept reporting it afterwards. In my app the symptom was “out of time” after the phone had been in a pocket, because the extension re-shielded and the balance drained to zero.
You can’t stop the event from firing, but you can refuse to believe it. Two facts about genuine thresholds do most of the work:
- Usage can’t outrun the wall clock.
used-7can’t legitimately fire less than 7 minutes after the monitor was armed. Every threshold action carriesneverTriggerBefore = armedAt + N minutes − 5s, so the extension skips impossible-early fires. - Real usage arrives incrementally; phantoms arrive as a burst. When the app reads back fired events, it treats it as a phantom when the lowest and highest thresholds fired within about 3 seconds of each other but span a minute or more of usage.
// Guard 1: drop thresholds that fired before they could have been reached
if (at < startedAt + minutes * 60_000 - 5_000) continue;
// Guard 2: lowest and highest fired in the same instant, a minute or more apart
if (highest.minutes - lowest.minutes >= 1 &&
Math.abs(highest.at - lowest.at) <= 3_000) {
return { usedMinutes: 0, partialMs: 0 }; // phantom: ignore the lot
}
Guard 2 runs in the app, not the extension, so a phantom can still put the shield up. The balance survives, though, and the next time the app is opened it lifts the shield again. Doing burst detection inside the extension would need custom Swift, which I haven’t written yet.
observed A related problem goes the other way: thresholds that should fire and don’t. With in-and-out usage on iOS 26, I saw a fresh monitor’s first 15-second threshold fire on time, and later ones get coalesced or dropped entirely (used-0.25 fired, used-0.5 and up never did). My workaround is to treat any monitor that has already metered usage as stale and re-arm a fresh one, over the remaining balance, every time the app goes to the background. That forgives up to 15 seconds of usage per restart, which I’m fine with.
9. A shield can’t open your app
The obvious design is a shield button that says “Earn more time” and opens your app. There’s no supported way to do it; Apple DTS says there’s no API for a shield extension to launch its parent app. A ShieldActionDelegate answers a button press with a ShieldActionResponse (.none, .close or .defer), and none of those opens anything.
observed The workaround you’ll find (NSExtensionContext().open(url) from the ShieldAction extension, which the library exposes as openUrl) did nothing on current iOS when I tested it on a device in September 2026. The button just closed the shield. Posting a notification from the button tap was unreliable for me too.
What I ship instead: the shield has a single “Close” button, and the ShieldConfiguration extension posts a local notification whenever the shield is displayed. The user taps the banner and lands in the app. The app writes the notification payload to the App Group ahead of time, and dismisses the notification when it comes to the foreground. iOS may ask for the shield configuration several times per display, so the post is debounced:
func postShieldShownNotification(placeholders: [String: String?]) {
guard let config = userDefaults?.dictionary(forKey: "honorfitShieldShownNotification"),
let payload = config["payload"] as? [String: Any] else { return }
let minInterval = config["minIntervalSeconds"] as? Double ?? 30
let now = Date().timeIntervalSince1970
let lastSentAt = userDefaults?.double(forKey: "honorfitShieldShownNotificationLastSentAt") ?? 0
if now - lastSentAt < minInterval { return }
userDefaults?.set(now, forKey: "honorfitShieldShownNotificationLastSentAt")
// builds a UNMutableNotificationContent and adds a UNNotificationRequest
// with a fixed identifier, so repeats replace rather than stack
sendNotification(contents: payload, placeholders: placeholders)
}
class ShieldConfigurationExtension: ShieldConfigurationDataSource {
override func configuration(shielding application: Application) -> ShieldConfiguration {
postShieldShownNotification(placeholders: [
"applicationOrDomainDisplayName": application.localizedDisplayName
])
return buildShield(/* title, subtitle, colors from the App Group */)
}
// ...same hook in the category and web-domain overloads
}
It’s a hack, and it depends on the user allowing notifications, but it’s the only route into the app that worked reliably for me.
10. Keeping custom extensions through prebuild
library If you use Expo, expo prebuild runs the react-native-device-activity config plugin, which by default copies the library’s stock extension sources into ./targets on every run. My customized ShieldConfiguration was silently replaced. The fix is one plugin option:
["react-native-device-activity", {
"appleTeamId": "XXXXXXXXXX",
"appGroup": "group.com.honorfit.blocker",
"copyToTargetFolder": false
}]
The cost: when you upgrade the library, diff its targets/ and shared Swift against yours by hand and re-apply your changes.
11. Xcode 26, the Simulator, and testing
- Build with the iOS 26 SDK. App Store Connect now rejects uploads built with an SDK older than iOS 26 (ITMS-90725). On EAS I pin the build image to
macos-tahoe-26.5-xcode-26.6. - The Simulator only gets you partway. On iOS 26 simulators, the authorization prompt and the picker do appear. But authorization never completes (“Allow with Passcode” wants a device passcode the Simulator can’t set), the picker has no apps to list, and the monitor extension never runs. Everything in sections 5 to 9 needs a physical device.
- Guard ManagedSettings calls on authorization. Touching the store while unauthorized popped a system passcode prompt on the Simulator, which broke my UI tests. I check authorization status before any shield call.
- Surface authorization errors in the UI. TestFlight testers don’t have a console. If
requestAuthorizationfails silently, the button just looks broken. I show the error and status in an alert. - Test overnight. My worst bugs (blocked all night with time left, music pausing) were invisible in code review and only reproduced on a device over hours.
12. And on Android
For contrast, Android has no sanctioned equivalent, so you build it yourself. HonorFit’s Android blocker is a foreground service (type specialUse, since app blocking fits none of the listed types) that polls UsageStatsManager once a second while the screen is on to learn which app is in front. When a blocked app is up and the balance is zero, it draws a TYPE_APPLICATION_OVERLAY lock screen over it (“Display over other apps”). A boot receiver and a watchdog alarm restart the service after reboots, app updates, and aggressive OEM battery managers.
The trade-offs point the opposite way from iOS. You see real package names, so privacy is a promise you keep rather than one the OS enforces. A blocked app can be visible for up to one poll interval, and it keeps running under the overlay. And you’ll write Play Console declarations for the foreground service type, the overlay permission and package visibility. I deliberately avoided an AccessibilityService, which gets the strictest review of all.
13. Checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| Provisioning fails for one target | Entitlement or App Group missing on that bundle ID | Request Family Controls (Distribution) and attach the App Group for every bundle ID |
| No threshold events, ever | intervalStart = now, so the monitor is booked for tomorrow | Backdate the start; keep the interval ≥ 15 min |
| Monitor meant for tomorrow runs today | Schedule contains the current time | Expired-today [00:00, T) schedule, or repeats: true with guarded actions |
| Apps flash-shield when your app opens | stopMonitoring fires intervalDidEnd | Clear actions before stopping; guard with a not-before time |
| “Out of time” after the phone sat idle | iOS 26 phantom thresholds | Wall-clock guard plus burst filter |
| Later thresholds never fire | Dropped/coalesced under intermittent use | Re-arm a fresh monitor on each background |
| Shield button won’t open the app | No supported API | Post a notification from ShieldConfiguration on display |
| Fired events don’t match (RN) | Underscore in event name | Use used-7, not used_7 |
| Picker opens empty (RN) | includeEntireCategory prop missing | Pass it, with any value |
14. Quick answers
Do I need the Family Controls entitlement for every extension?
Yes. It’s granted per bundle ID. The app plus a DeviceActivityMonitor, a ShieldConfiguration and a ShieldAction extension is four approvals, and each App ID also needs the shared App Group.
Why is DeviceActivity eventDidReachThreshold not firing?
For me it was usually a non-repeating schedule starting at “now”, which gets booked for tomorrow. Backdate intervalStart. On iOS 26, under in-and-out usage, later sub-minute thresholds also got dropped; re-arming a fresh monitor helped.
Can a ShieldAction or ShieldConfiguration extension open the parent app?
Not with a supported API, and the NSExtensionContext workaround did nothing on current iOS in my testing. A local notification posted from ShieldConfiguration when the shield is displayed is what I ship.
Can I test the Screen Time API in the iOS Simulator?
Partly. The prompt and picker appear on iOS 26 simulators, but authorization didn’t complete, the picker had nothing to list, and the monitor extension never ran. Use a device.
15. Why I needed all this
I built HonorFit, an app blocker where you earn screen time by doing pushups. It works on the honor system: you type in your reps, and there’s no camera, no AI and no network calls. Everything above is what it took to make “your earned minutes only drain while a blocked app is on screen” work on an iPhone. If you want to see it running, it’s on the App Store and Google Play. If you’ve hit a Screen Time behavior that isn’t here, or found that something above is wrong on a newer iOS, I’d like to hear about it.