I spent the last few months building Prayer Lock, an iPhone app that blocks distracting apps during a scheduled daily prayer time. The whole product rests on Apple's Screen Time API: FamilyControls, ManagedSettings and DeviceActivity. The documentation is thin and the sharp edges are real. This is the write-up I wish I had on day one.
The app is React Native (Expo) with a native Swift module, but almost everything below applies to a pure Swift app too.
The three frameworks and who does what
-
FamilyControls – authorization and the system app picker (
FamilyActivityPicker). You never see bundle IDs; you get opaqueApplicationTokens. -
ManagedSettings – the actual blocking.
ManagedSettingsStore().shield.applications = tokensputs a system shield over those apps. Setting it tonilremoves it. -
DeviceActivity – schedules.
DeviceActivityCenter.startMonitoringwakes an extension at interval start and end, even when your app is dead.
Plus two extension points for the shield itself: ShieldConfigurationDataSource (how the shield looks) and ShieldActionDelegate (what its buttons do).
1. Authorization: individual, not parental
import FamilyControls
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
.individual is what you want for a self-control app. .child is for parental controls and requires Family Sharing. The entitlement com.apple.developer.family-controls must be requested from Apple through a form, and it's granted per bundle ID, so every app extension needs its own approval. Budget a week.
2. Tokens live in an App Group, not your database
FamilyActivitySelection is Codable, but the tokens inside are only meaningful on the device that produced them. Don't sync them to your backend. Store the selection in the shared container so extensions can read it:
static let appGroupId = "group.com.prayerlock.ios.shared"
func saveSelection(_ selection: FamilyActivitySelection) {
let defaults = UserDefaults(suiteName: Self.appGroupId)
defaults?.set(try? JSONEncoder().encode(selection), forKey: "selectedAppsData")
}
Every extension and the main app share one ManagedSettingsStore() keyed by the app group, so whoever applies the shield, the others see the same state.
3. Scheduling: the 15-minute floor and the dead-app problem
let schedule = DeviceActivitySchedule(
intervalStart: DateComponents(hour: 7, minute: 0),
intervalEnd: DateComponents(hour: 7, minute: 30),
repeats: true
)
try DeviceActivityCenter().startMonitoring(.prayerTime, during: schedule)
Things that cost me time:
- Minimum interval is 15 minutes. A "lock for 5 minutes" session still has to be scheduled as 15; you unblock early from the app when the session completes.
-
Your app is not running when the interval fires. The work happens in a
DeviceActivityMonitorextension:
class PrayerActivityMonitor: DeviceActivityMonitor {
private let store = ManagedSettingsStore()
override func intervalDidStart(for activity: DeviceActivityName) {
guard let selection = AppBlockingManager.shared.loadSelection(),
!hasPrayedToday() else { return }
store.shield.applications = selection.applicationTokens
store.shield.applicationCategories = .specific(selection.categoryTokens)
}
override func intervalDidEnd(for activity: DeviceActivityName) {
store.shield.applications = nil
store.shield.applicationCategories = nil
}
}
- Extensions have no console you can see. Write your own log to the app group (
UserDefaultsarray) and add a hidden screen in the app that dumps it. I would not have shipped without this. - The simulator doesn't render shields and often doesn't fire monitors on time. Test on a device.
4. The shield button cannot open your app. Full stop.
This is the one everyone hits. Your ShieldActionDelegate gets handle(action:for:completionHandler:) and may respond with .none, .close or .defer. There is no UIApplication, no NSExtensionContext, no URL opening. Apple Developer Forums thread 719905 and several Feedbacks confirm it across iOS 16–26. The private-API hacks you'll find on Stack Overflow don't work and will get you rejected.
What does work: the extension can post a local notification, and the user tapping that notification is the one sanctioned path back into your app.
override func handle(action: ShieldAction, for application: ApplicationToken,
completionHandler: @escaping (ShieldActionResponse) -> Void) {
switch action {
case .primaryButtonPressed:
let defaults = UserDefaults(suiteName: appGroupId)
defaults?.set(true, forKey: "shieldRequestedPrayer")
defaults?.set(Date().timeIntervalSince1970, forKey: "shieldRequestedPrayerAt")
let content = UNMutableNotificationContent()
content.title = "Time to pray"
content.body = "Tap to start your prayer."
content.interruptionLevel = .timeSensitive
UNUserNotificationCenter.current().add(
UNNotificationRequest(identifier: "shield-prayer", content: content, trigger: nil))
completionHandler(.close)
default:
completionHandler(.defer)
}
}
Then, in the app, on AppState becoming active, read and clear the flag (with a freshness window, say five minutes) and route straight into the session. Two bugs this design produced for me, both invisible in testing:
-
Notification permission was never requested on the path where users enabled blocking. Without it the button looks dead. Ask for notifications the moment Screen Time is granted, and show a banner with a Settings link if denied.
.timeSensitiveneeds its own entitlement on the app and the shield-action extension. -
Cold-start race. My auth store set
userbefore the profile finished loading; the shield consumer pushed the session screen, then the root redirect replaced it with Home. Gate the consumer on "auth fully loaded", not on "user exists".
5. The shield is a tint, not a background
ShieldConfiguration.backgroundColor is blended over the system material. A saturated orange renders as pale peach on device, and the simulator won't show you. Design the shield with dark text on a light base, use backgroundBlurStyle: .systemUltraThinMaterialLight, and put your brand color on the button, not behind the text. Localize the copy inside the extension; it doesn't see your app's i18n.
6. Wrapping it for Expo
The module is a standard Expo Module (expo-module.config.json + Swift). The pieces that need care:
- A config plugin adds the Family Controls entitlement and the App Group to the main target. Extensions still have to be created in Xcode by hand; config plugins can't add targets, so keep extension sources in a
extensions/folder and copy them intoios/on prebuild. - Wrap
requireNativeModuleintry/catchand fall back to a no-op implementation so Expo Go and Android builds don't crash. - Expose the picker as a native view (
FamilyActivityPickerinside a SwiftUIUIHostingController) with anonSelectionChangeevent; the JS side only ever learns the count.
AsyncFunction("scheduleBlocking") { (hour: Int, minute: Int, durationMinutes: Int, bufferMinutes: Int) -> Bool in
ScheduleManager.shared.schedule(hour: hour, minute: minute,
duration: durationMinutes, buffer: bufferMinutes)
}
What I'd tell past me
- Apply for the entitlement first; everything else can wait for it.
- Treat the App Group as the single source of truth and the extensions as the real runtime.
- Design around "the shield cannot open the app" from day one. The notification handoff is fine once the permission is in place.
- Build the hidden log screen before the first TestFlight.
If you're building in this space, the Apple forums thread above is the best place to add your voice; the more Feedbacks on the shield-to-app handoff, the better the odds it changes.
I'm the developer of Prayer Lock. Everything above is from shipping it; corrections welcome in the comments.
Top comments (1)
The 15-minute minimum interval is the detail I hadn't seen documented anywhere else — did you end up scheduling 15 minutes and unblocking early from the app, and did that ever race with the monitor extension? The hidden log screen tip alone is worth bookmarking.