L’API Screen Time d’iOS en 2026 : ce que la doc d’Apple ne dit pas

FamilyControls, DeviceActivity et les extensions de shield, vus depuis un bloqueur d’applis publié. Par Thomas Willheim · 30 septembre 2026

J’ai passé août et septembre 2026 à publier un bloqueur d’applis sur iOS. Il utilise les frameworks de Temps d’écran (FamilyControls, DeviceActivity, ManagedSettings et ManagedSettingsUI) depuis React Native, via Expo SDK 57 et la bibliothèque react-native-device-activity (0.6.1). La documentation d’Apple te dit ce qu’est chaque type. Elle dit très peu de choses sur la façon dont ils se comportent sur un vrai téléphone, pendant la nuit, sous iOS 26.

Voici la liste que j’aurais aimé avoir. Les comportements que j’ai constatés sur un appareil mais que je ne trouve documentés nulle part sont marqués observé ; ils ne sont peut-être pas vrais sur toutes les versions d’iOS. Les points propres à la bibliothèque React Native sont marqués bibliothèque ; saute-les si tu écris directement en Swift.

1. Les pièces du puzzle

« L’API Screen Time », ce sont quatre frameworks et, en pratique, trois extensions d’appli :

Tout l’intérêt est dans les extensions. Quand un utilisateur n’a plus de temps, mon appli est en général suspendue ou tuée, donc c’est l’extension de monitoring qui remet le shield en place. Conçois tout comme si le processus de ton appli n’était jamais là au moment où les choses se passent.

2. Entitlements : une demande par bundle ID

L’entitlement Family Controls (Distribution) est approuvé par bundle ID. Avec trois extensions, ça fait quatre demandes. Les miennes :

Une fois qu’elles sont approuvées, active la capability Family Controls (Distribution) sur chaque App ID dans le portail développeur, puis crée un App Group et rattache-le aux quatre. S’il en manque un, la provision de cette cible échoue. Les approbations ne sont pas instantanées, alors envoie les quatre demandes dès le premier jour, avant d’avoir écrit beaucoup de code.

Au final, le fichier d’entitlements de chaque extension est identique :

<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
  <string>group.com.honorfit.blocker</string>
</array>

Sur Expo/EAS, le service de build ne crée de profils de provisioning que pour les extensions qu’il connaît. Je déclare chaque cible, avec ses entitlements, sous extra.eas.build.experimental.ios.appExtensions dans 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. L’App Group est le seul canal

Ton appli et ses extensions sont des processus séparés qui ne tournent presque jamais en même temps. La seule chose qu’ils partagent, c’est le conteneur de l’App Group, en général via UserDefaults(suiteName:). Tout ce dont les extensions ont besoin (la sélection enregistrée, le texte du shield, quoi faire quand un seuil se déclenche) doit y être écrit à l’avance.

react-native-device-activity pousse cette logique jusqu’au bout. L’appli écrit une liste d’« actions » sous une clé du genre actions_for_{activity}_{callback}_{event}, et l’extension de monitoring va les chercher au moment où le callback se déclenche. Conséquence pratique : tu peux changer ce que fait un moniteur déjà lancé sans le redémarrer. Les extensions appellent aussi CFPreferencesAppSynchronize avant de lire, pour voir les dernières écritures de l’appli :

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. Les jetons sont opaques, et le sélecteur

Le sélecteur donne à ton appli des ApplicationToken, des ActivityCategoryToken et des WebDomainToken, et rien d’autre. C’est voulu : l’appli ne peut pas retransformer un jeton en « Instagram ». Ce qu’elle peut faire, c’est les compter, donc mon écran de liste de blocage affiche « 3 applis, 1 catégorie ». C’est tout le modèle de confidentialité, et il est bon : mon appli ne sait littéralement pas ce que tu as bloqué.

Le sélecteur d’applis de Temps d’écran sur iOS : une liste de catégories d’applis, avec Santé et forme dépliée pour montrer ses applis
Le FamilyActivityPicker d’Apple. L’utilisateur voit les noms et les icônes des applis ; ton appli ne reçoit en retour que des jetons opaques.

L’extension de shield, c’est différent. Là, le système te passe une Application pour l’appli bloquée, et j’utilise son localizedDisplayName dans le texte du shield. L’appli elle-même ne voit jamais que des jetons.

Deux remarques pratiques :

<DeviceActivitySelectionSheetViewPersisted
  familyActivitySelectionId="distracting-apps"
  includeEntireCategory={false}   // must be present, or nothing preloads
  onSelectionChange={(e) => setCounts(e.nativeEvent)}
  onDismissRequest={() => setPickerVisible(false)}
/>

bibliothèque Encore une : blockSelection de la bibliothèque ajoute des jetons à une liste de blocage persistante. Quand l’utilisateur modifie sa sélection, les applis retirées restent bloquées. J’appelle d’abord resetBlocks(), puis je rebloque la sélection actuelle, à chaque fois.

5. Mesurer l’utilisation avec des événements de seuil

HonorFit te donne un solde de minutes qui ne diminue que lorsqu’une appli bloquée est à l’écran. DeviceActivity n’a pas d’appel du genre « dis-moi combien de temps il l’a utilisée » ; il a des événements de seuil qui se déclenchent une fois que l’utilisation cumulée d’une sélection atteint une durée. Donc j’enregistre un événement toutes les 15 secondes d’utilisation, nommé en minutes : used-0.25, used-0.5, … jusqu’au solde. Les quarts sont exacts en virgule flottante binaire, donc les noms font l’aller-retour par Number() sans perte. Sous la bibliothèque, c’est l’API d’Apple toute simple :

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 (la valeur par défaut) signifie que l’utilisation antérieure à l’enregistrement des événements ne compte pas. C’est important pour l’astuce de planning de la section suivante.

Quand le dernier événement se déclenche, l’extension de monitoring remet le shield et envoie une notification « temps écoulé ». Chaque événement de 15 secondes met à jour une seule notification avec un identifiant fixe, donc elle se remplace sur place et sert de décompte en direct approximatif. interruptionLevel = .passive la livre discrètement dans le Centre de notifications, sans bannière.

bibliothèque Ne mets pas de tirets bas (underscores) dans les noms d’événements ou d’activités. La bibliothèque enregistre les événements déclenchés sous events_{activity}_{callback}_{eventName} et les relit en découpant sur _. Un événement appelé used_7 revient sous le nom used, et plus rien ne correspond. Le symptôme : un solde qui ne baissait jamais.

6. Les plannings ne démarrent pas quand tu crois

Si tes événements de seuil ne se déclenchent jamais, sans un bruit, c’est probablement ça.

« Maintenant » est déjà dans le passé

observé Un DeviceActivitySchedule non répété est comparé à des composants de date, et le moniteur exécute le prochain intervalle qui correspond. Si tu passes l’heure actuelle comme intervalStart, le temps que le framework l’évalue, ce moment est passé. La prochaine correspondance, c’est demain, et aujourd’hui rien ne se déclenche : pas de seuils, pas de callbacks, pas d’erreur. La solution est d’antidater le début pour que « maintenant » soit dans l’intervalle :

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

Antidater ne peut pas faire payer à l’utilisateur le scroll d’avant, grâce à includesPastActivity: false. Ça garde aussi l’intervalle au-dessus du minimum d’Apple : les plannings de moins de 15 minutes sont refusés, ce qui piège tous ceux qui lancent une session juste avant minuit.

Un planning qui contient « maintenant » démarre immédiatement

observé Le revers de la médaille : un planning 00:00–23:59:59 contient l’heure actuelle, donc il démarre maintenant, pas au prochain minuit. J’ai trouvé deux façons de réserver le lendemain :

observé N’utilise pas de composants année/mois/jour complets dans un planning pour viser une date précise. Les callbacks des extensions sont devenus peu fiables quand j’ai essayé, ce qui rejoint des signalements sur les Apple Developer Forums (fil 729841). Tiens-t’en à heure/minute/seconde.

Une réserve : ces comportements autour de minuit n’apparaissent que sur un appareil, pendant la nuit. J’ai mis des garde-fous autour, mais je n’ai pas instrumenté autant de vrais minuits que je l’aurais voulu, alors prends cette section comme « ce que j’ai vu », pas comme une spécification.

7. stopMonitoring déclenche intervalDidEnd

observé Appeler stopMonitoring, ou appeler startMonitoring avec le nom d’un moniteur déjà lancé, fait appeler par iOS intervalDidEnd sur ton extension, comme si le planning s’était terminé normalement. Si ton intervalDidEnd remet le shield par sécurité (le mien le fait, pour qu’un moniteur ne puisse pas expirer en laissant des applis débloquées), alors chaque fois que l’appli arrête un moniteur, l’extension bloque tout pendant un bref instant.

C’est un utilisateur qui a signalé le symptôme : Spotify se mettait en pause chaque fois qu’il ouvrait HonorFit. Arrêter le moniteur faisait bloquer la sélection par l’extension un instant, ce qui suspendait Spotify, avant que l’appli ne retire de nouveau les shields.

Deux correctifs, et il faut les deux. D’abord, supprime les actions de l’extension avant d’arrêter, pour qu’elle n’ait rien à exécuter :

function stopActivities(names: string[]): void {
  for (const name of names) RNDA.cleanUpAfterActivity(name); // clear actions first
  RNDA.stopMonitoring(names);                                // then stop
}

Ensuite, donne à chaque action d’intervalDidEnd et d’intervalDidStart un horodatage « pas avant » fixé à la vraie limite du planning, moins une minute de marge. L’extension le vérifie avant d’exécuter quoi que ce soit :

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

En Swift pur : enregistre la vraie heure de fin dans l’App Group et fais en sorte qu’intervalDidEnd abandonne s’il est appelé trop tôt.

8. Les seuils fantômes d’iOS 26

Sur iOS 26.x, eventDidReachThreshold se déclenche parfois sans aucune utilisation réelle : juste après la programmation, ou quand un téléphone resté verrouillé un moment est repris en main ou branché. Apple l’a reconnu dans des fils du forum développeurs (809410, 811305, 814559). Ce serait corrigé dans une bêta de 26.5, mais des gens ont continué à le signaler ensuite. Dans mon appli, le symptôme était « plus de temps » après un passage du téléphone dans la poche, parce que l’extension remettait le shield et que le solde tombait à zéro.

Tu ne peux pas empêcher l’événement de se déclencher, mais tu peux refuser d’y croire. Deux faits sur les vrais seuils font l’essentiel du travail :

  1. L’utilisation ne peut pas aller plus vite que l’horloge. used-7 ne peut pas légitimement se déclencher moins de 7 minutes après l’armement du moniteur. Chaque action de seuil porte neverTriggerBefore = armedAt + N minutes − 5s, donc l’extension ignore les déclenchements impossiblement précoces.
  2. L’utilisation réelle arrive petit à petit ; les fantômes arrivent en rafale. Quand l’appli relit les événements déclenchés, elle considère qu’il s’agit d’un fantôme si le seuil le plus bas et le plus haut se sont déclenchés à environ 3 secondes d’écart mais couvrent une minute d’utilisation ou plus.
// 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
}

Le garde-fou 2 tourne dans l’appli, pas dans l’extension, donc un fantôme peut quand même remettre le shield. Le solde, lui, survit, et la prochaine fois que l’appli est ouverte, elle retire le shield. Détecter les rafales dans l’extension demanderait du Swift sur mesure, que je n’ai pas encore écrit.

observé Un problème voisin va dans l’autre sens : des seuils qui devraient se déclencher et ne le font pas. Avec une utilisation par à-coups sur iOS 26, j’ai vu le premier seuil de 15 secondes d’un nouveau moniteur se déclencher à l’heure, puis les suivants être fusionnés ou carrément perdus (used-0.25 s’est déclenché, used-0.5 et au-delà jamais). Mon contournement : considérer comme périmé tout moniteur qui a déjà mesuré de l’utilisation, et en réarmer un nouveau, sur le solde restant, chaque fois que l’appli passe en arrière-plan. Ça fait cadeau de 15 secondes d’utilisation au maximum par redémarrage, ce qui me va très bien.

9. Un shield ne peut pas ouvrir ton appli

Le design évident, c’est un bouton sur le shield qui dit « Gagner plus de temps » et ouvre ton appli. Il n’y a aucun moyen officiel de le faire ; le DTS d’Apple dit qu’il n’existe pas d’API permettant à une extension de shield de lancer son appli parente. Un ShieldActionDelegate répond à un appui sur un bouton avec une ShieldActionResponse (.none, .close ou .defer), et aucune d’elles n’ouvre quoi que ce soit.

observé Le contournement qu’on trouve partout (NSExtensionContext().open(url) depuis l’extension ShieldAction, que la bibliothèque expose sous le nom openUrl) n’a rien fait sur les iOS actuels quand je l’ai testé sur un appareil en septembre 2026. Le bouton fermait simplement le shield. Envoyer une notification depuis l’appui sur le bouton n’était pas fiable non plus chez moi.

Ce que je publie à la place : le shield a un seul bouton « Fermer », et l’extension ShieldConfiguration envoie une notification locale chaque fois que le shield est affiché. L’utilisateur appuie sur la bannière et arrive dans l’appli. L’appli écrit à l’avance le contenu de la notification dans l’App Group, et retire la notification quand elle revient au premier plan. iOS peut demander la configuration du shield plusieurs fois par affichage, donc l’envoi est soumis à un délai minimum (debounce) :

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
}

C’est du bricolage, et ça dépend de l’autorisation des notifications par l’utilisateur, mais c’est la seule route vers l’appli qui a marché de façon fiable chez moi.

10. Garder ses extensions personnalisées au prebuild

bibliothèque Si tu utilises Expo, expo prebuild exécute le config plugin de react-native-device-activity, qui par défaut copie les sources d’extension d’origine de la bibliothèque dans ./targets à chaque lancement. Ma ShieldConfiguration personnalisée a été remplacée sans prévenir. La solution tient en une option du plugin :

["react-native-device-activity", {
  "appleTeamId": "XXXXXXXXXX",
  "appGroup": "group.com.honorfit.blocker",
  "copyToTargetFolder": false
}]

Le prix à payer : quand tu mets à jour la bibliothèque, compare à la main son dossier targets/ et son Swift partagé avec les tiens, et réapplique tes modifications.

11. Xcode 26, le simulateur et les tests

12. Et sur Android

Pour comparer : Android n’a pas d’équivalent officiel, donc tu le construis toi-même. Le bloqueur Android d’HonorFit est un service de premier plan (type specialUse, puisque le blocage d’applis ne rentre dans aucun des types listés) qui interroge UsageStatsManager une fois par seconde tant que l’écran est allumé, pour savoir quelle appli est au premier plan. Quand une appli bloquée est affichée et que le solde est à zéro, il dessine par-dessus un écran de verrouillage TYPE_APPLICATION_OVERLAY (« Afficher par-dessus d’autres applis »). Un receiver de démarrage et une alarme de surveillance relancent le service après un redémarrage, une mise à jour de l’appli ou le passage d’un gestionnaire de batterie agressif de constructeur.

Les compromis vont dans le sens inverse d’iOS. Tu vois les vrais noms de paquets, donc la confidentialité est une promesse que tu tiens toi-même plutôt qu’une garantie imposée par l’OS. Une appli bloquée peut rester visible jusqu’à un intervalle d’interrogation, et elle continue de tourner sous la superposition. Et tu vas devoir rédiger des déclarations dans la Play Console pour le type de service de premier plan, l’autorisation de superposition et la visibilité des paquets. J’ai volontairement évité un AccessibilityService, qui subit l’examen le plus strict de tous.

13. Check-list

SymptômeCause probableSolution
La provision échoue pour une cibleEntitlement ou App Group manquant sur ce bundle IDDemander Family Controls (Distribution) et rattacher l’App Group pour chaque bundle ID
Aucun événement de seuil, jamaisintervalStart = maintenant, donc le moniteur est réservé pour demainAntidater le début ; garder l’intervalle ≥ 15 min
Un moniteur prévu pour demain tourne aujourd’huiLe planning contient l’heure actuellePlanning déjà expiré [00:00, T), ou repeats: true avec des actions protégées
Les applis se bloquent une fraction de seconde quand ton appli s’ouvrestopMonitoring déclenche intervalDidEndEffacer les actions avant d’arrêter ; protéger avec une heure « pas avant »
« Plus de temps » après un téléphone resté inactifSeuils fantômes d’iOS 26Garde-fou sur l’horloge plus filtre anti-rafale
Les seuils suivants ne se déclenchent jamaisPerdus/fusionnés en cas d’utilisation intermittenteRéarmer un nouveau moniteur à chaque passage en arrière-plan
Le bouton du shield n’ouvre pas l’appliAucune API officielleEnvoyer une notification depuis ShieldConfiguration à l’affichage
Les événements déclenchés ne correspondent pas (RN)Tiret bas dans le nom de l’événementUtiliser used-7, pas used_7
Le sélecteur s’ouvre vide (RN)Prop includeEntireCategory manquanteLa passer, avec n’importe quelle valeur

14. Réponses rapides

Faut-il l’entitlement Family Controls pour chaque extension ?

Oui. Il est accordé par bundle ID. L’appli plus une extension DeviceActivityMonitor, une ShieldConfiguration et une ShieldAction, ça fait quatre approbations, et chaque App ID a aussi besoin de l’App Group partagé.

Pourquoi eventDidReachThreshold de DeviceActivity ne se déclenche pas ?

Chez moi, c’était en général un planning non répété qui commençait à « maintenant », et qui se retrouvait réservé pour demain. Antidate intervalStart. Sur iOS 26, avec une utilisation par à-coups, des seuils ultérieurs de moins d’une minute disparaissaient aussi ; réarmer un nouveau moniteur a aidé.

Une extension ShieldAction ou ShieldConfiguration peut-elle ouvrir l’appli parente ?

Pas avec une API officielle, et le contournement via NSExtensionContext n’a rien fait sur les iOS actuels lors de mes tests. Ce que je publie, c’est une notification locale envoyée par ShieldConfiguration quand le shield s’affiche.

Peut-on tester l’API Screen Time dans le simulateur iOS ?

En partie. La demande et le sélecteur s’affichent sur les simulateurs iOS 26, mais l’autorisation n’a pas abouti, le sélecteur n’avait rien à lister et l’extension de monitoring ne s’est jamais exécutée. Utilise un appareil.

15. Pourquoi j’ai eu besoin de tout ça

J’ai créé HonorFit, un bloqueur d’applis où tu gagnes ton temps d’écran en faisant des pompes. Il fonctionne sur le système de l’honneur : tu notes tes répétitions, et il n’y a ni caméra, ni IA, ni appel réseau. Tout ce qui précède, c’est ce qu’il a fallu pour que « tes minutes gagnées ne diminuent que lorsqu’une appli bloquée est à l’écran » fonctionne sur un iPhone. Si tu veux le voir tourner, il est sur l’App Store et Google Play. Si tu es ici parce que tu cherches une appli de ce genre plutôt que d’en construire une, j’ai expliqué comment fonctionnent les bloqueurs d’applis avec des pompes et fait un comparatif honnête de ceux qui existent, concurrents compris. Si tu as rencontré un comportement de Temps d’écran qui n’est pas ici, ou constaté que quelque chose ci-dessus est faux sur un iOS plus récent, ça m’intéresse.

À lire aussi