I'm Ayumu Kitabayashi, and I build minulo on my own. It's an iPhone app that hides Reels and Shorts. To block the official Instagram and YouTube apps, it uses Apple's Screen Time API (FamilyControls / ManagedSettings / DeviceActivity).
With this API, your app never receives the names of the apps the user picked. All you get are opaque values (tokens). That's a bit awkward when you're building a blocker, but it made filling out the App Store privacy labels honestly a lot easier. Here's the actual code and the places where I got stuck.
The app is React Native (Expo). The Screen Time part uses Robert Herber's react-native-device-activity with some changes of my own, and the Swift below comes from that native side, trimmed a little.
What Apple's docs say
The FamilyActivitySelection page says:
To protect the user's privacy, FamilyActivitySelection holds opaque values that represent categories, applications, and web domains selected by the user.
(https://developer.apple.com/documentation/familycontrols/familyactivityselection)
FamilyActivityPicker, the selection screen, is likewise described as working "without revealing their choices to the app". All your app can do is hand those values to ManagedSettings and DeviceActivity.
Storing the selection: Codable into an App Group
FamilyActivitySelection is Codable, so you can encode it to JSON and store it as a base64 string. In minulo, the library function writes it out this way:
func serializeFamilyActivitySelection(selection: FamilyActivitySelection) -> String {
let encoder = JSONEncoder()
do {
let json = try encoder.encode(selection)
return json.base64EncodedString()
} catch {
return ""
}
}
It goes into the App Group's UserDefaults. The DeviceActivityMonitor extension is what lifts and reapplies blocks, so the selection has to live somewhere both the app and the extension can read.
let appGroup = Bundle.main.object(forInfoDictionaryKey: "REACT_NATIVE_DEVICE_ACTIVITY_APP_GROUP") as? String
var userDefaults = UserDefaults(suiteName: appGroup)
// one selection per id (blocked during the day, always allowed, allowed at bedtime, etc.)
familyActivitySelectionIds[id] = serializeFamilyActivitySelection(selection: activitySelection)
userDefaults?.set(familyActivitySelectionIds, forKey: "familyActivitySelectionIds")
If you decode the base64 you'll see arrays like applicationTokens, but the values only mean something on that device. Storing them as a string doesn't let you, the developer, learn any app names.
Shielding: pass the tokens straight to ManagedSettingsStore
To block, read the saved selection back and put it into shield:
let store = ManagedSettingsStore()
store.shield.applications = blocklist.applicationTokens
store.shield.webDomains = blocklist.webDomainTokens
if blocklist.categoryTokens.count > 0 {
store.shield.applicationCategories = .specific(
blocklist.categoryTokens,
except: whitelist.applicationTokens // always-allowed apps
)
} else {
store.shield.applicationCategories = nil
}
App names never show up here either. Having the token is enough; the block works without knowing which app it is.
You can draw the name with Label(token), but you can't read it as text
The trouble starts when you want to list the selected apps on screen. Pass a token to SwiftUI's Label and you get the icon and name:
ForEach(Array(shownApps.enumerated()), id: \.offset) { _, token in
Label(token)
}
Apple calls this a "read-only, visual representation" (https://developer.apple.com/documentation/familycontrols/displayingactivitylabels). What you get is a view drawn by the system, and there's no API to pull a string out of it.
minulo is React Native, so the JS side can't receive the names and build the list itself. Instead, each row is a native view. I pass in only the base64 selection and the row index, and Swift draws Label(token).
There's also localizedDisplayName on Application. According to the docs, though, it's only filled in inside the extension that returns the shield's appearance (ShieldConfiguration). Everywhere else it's nil (https://developer.apple.com/documentation/managedsettings/application/localizeddisplayname).
Where I got stuck
1. The Set's order changes every time
applicationTokens is a Set. I wanted a tap on the third row to unlock just that app, but the order changed on every redraw, and a different app opened than the one tapped. Since I can't sort by name, I sort by the encoded token string. What matters is that the display side and the action side sort the same way.
let tokens = Array(sel.applicationTokens).sorted {
(try? JSONEncoder().encode($0))?.base64EncodedString() ?? ""
< (try? JSONEncoder().encode($1))?.base64EncodedString() ?? ""
}
2. Same contents, different base64
Sometimes I'd open the picker, close it without changing anything, and get back a different base64 string. Comparing the strings directly flagged that as a change, so now I decode, sort the tokens, and compare those.
3. A malformed string crashes the app
The library force-unwrapped the result where it opens the base64, so passing a malformed string crashed on the Swift side. A JS try/catch can't catch that. And since blocks are reapplied on every launch, once a bad string was saved the app couldn't start anymore. I now check the length and character set in JS before passing anything down.
4. Record the count at selection time
I wanted to show "Blocking 3 apps", but the function that counts a selection's contents later sometimes returned 0. So I store the counts (apps, categories, sites) from the event that fires the moment the user makes a selection. I can't know the names, but I can keep the counts.
5. Ask the user and remember the answer
When someone taps "Open in minulo" on the block screen, ideally you'd decide which social app to open from the app's name. But Apple sometimes doesn't give the extension a name. In that case, I ask the user inside minulo which app it was, and save the token and their answer as a pair in the App Group. That stays on the device too and is never sent anywhere.
When authorization is revoked
The FamilyActivitySelection docs also say:
If a user, parent, or guardian revokes authorization of your app, any tokens that FamilyActivitySelection provided while your app was authorized are voided.
Saved tokens stop working once authorization is revoked. In minulo, I read the authorization status at launch and when the app comes back to the foreground, and turn blocking off only when it clearly reads as "denied", showing a notice once. Right after launch the status can briefly read as "not determined", and turning blocking off at that point silently removed a block that was working. When the user authorizes again later, I think it's safer not to assume the old tokens still work and to let them pick their apps again.
App Store privacy labels
Apple defines "collect" as transmitting data off the device in a way that lets you or third parties access it for longer than needed to service the request in real time. It also says data processed only on device doesn't need to be disclosed:
Data that is processed only on device is not "collected" and does not need to be disclosed in your answers. If you derive anything from that data and send it off device, the resulting data should be considered separately.
(https://developer.apple.com/app-store/app-privacy-details/)
For minulo, the tokens for the selected apps live only in the App Group and in on-device records, and none of it leaves the phone. You can't get app names from tokens anyway, so "which apps the user blocks" isn't something there is to disclose.
The second sentence is the one to watch. minulo sends a "saved a block schedule" event to its analytics tool (PostHog), with the number of selected apps attached. That's a value derived from the tokens going off device, so I disclose it as usage data. A rule that helps me not miss things: even without names, if you send counts or numbers, they go in the label.
Summary
- FamilyActivitySelection is Codable. Encode it as JSON in base64 and put it in the App Group, and both the app and its extensions can read it
- Shielding just takes the tokens passed to ManagedSettingsStore; no names needed
-
Label(token)can show the name, but you can't read it as text.localizedDisplayNameonly exists inside the shield appearance extension - Set ordering, base64 that changes, malformed strings, and getting counts all need handling on your side
- Tokens are voided when authorization is revoked
- If tokens never leave the device there's nothing to disclose, but if you send numbers derived from them, disclose those
I wrote up the timing bugs I hit building timed blocks with DeviceActivity here.
What minulo sends and what it can't see is summarized on this page: https://minulo.app/en/is-minulo-safe.html?ref=devto-1024
Top comments (1)
On the authorization check: reading the status once at launch is where the brief "not determined" bites. If you observe it instead (AuthorizationCenter's authorizationStatus is published) and treat "not determined" as "leave the existing shield alone", you only act on a real change to denied, which is the behavior you ended up with.
For the token sort in #1, JSONEncoder doesn't guarantee key order unless you set outputFormatting to .sortedKeys. If a token's encoding ever includes a keyed container, the same token could sort differently between runs, so I'd set it on that encoder, or compare a hash of the sorted encodings for the change check in #2.