Bonjour, je m'appelle Ayumu Kitabayashi et je développe seul minulo, une app iPhone qui masque les Reels d'Instagram, les Shorts de YouTube et le fil « Pour toi » de TikTok, tout en gardant les messages privés. L'app est écrite avec Expo / React Native. Pour la partie Temps d'écran, j'utilise la bibliothèque react-native-device-activity de Robert Herber, complétée par mon propre code.
minulo propose un blocage minuté : on verrouille l'app officielle Instagram avec Temps d'écran pendant, par exemple, 30 minutes, et elle doit se débloquer toute seule à l'heure prévue, même si minulo n'est pas lancée. J'ai buté cinq fois, et à chaque fois sur la même chose : la façon dont iOS interprète le début et la fin d'une plage horaire.
Le code ci-dessous est en TypeScript, mais il appelle en dessous DeviceActivityCenter.startMonitoring avec un DeviceActivitySchedule. Les mêmes problèmes se posent donc si vous écrivez directement en Swift.
Le principe
- Au moment où l'on lance le blocage,
ManagedSettingsStoreapplique le bouclier (shield) sur l'app officielle. - À l'heure de fin, le blocage doit se lever, que l'app tourne ou non.
Pour le point 2, l'app ne peut pas compter sur elle-même : elle est peut-être suspendue. On réserve donc une plage intervalStart–intervalEnd dans un DeviceActivitySchedule, et c'est l'extension DeviceActivityMonitor qui lève le blocage dans intervalDidEnd.
1. Les plages de moins de 15 minutes sont refusées
Si l'on passe une plage de 5 minutes à startMonitoring, on obtient une erreur. Lors de mes tests sur appareil, tout ce qui faisait moins de 15 minutes était refusé.
Ma première parade était « maintenant + 15 minutes ». Résultat : une pause de 5 minutes restait ouverte 15 minutes. La bonne solution consiste à garder la fin fixe et à reculer le début :
// on garde la fin ; on recule le début pour que la plage fasse 15 minutes
export function shortWindow(untilMs: number) {
const now = new Date();
const e = new Date(Math.ceil(Math.max(untilMs, now.getTime() + 60000) / 60000) * 60000);
if (e.getTime() - now.getTime() >= 15 * 60000) return { s: now, e, dated: false };
const s = new Date(e.getTime() - 15 * 60000);
// si le début tombe la veille, on ajoute la date (voir le point 3)
return { s, e, dated: s.getDate() !== now.getDate() };
}
La fin est arrondie à la minute supérieure. Le début, lui, se retrouve alors un peu dans le passé ; ce qui compte pour moi, c'est que intervalDidEnd arrive à l'heure de fin.
2. Même heure et même minute au début et à la fin : refusé
Avec des DateComponents qui ne contiennent que l'heure et la minute, un blocage de 24 heures (de 13 h 05 aujourd'hui à 13 h 05 demain) se lit comme « début = fin », et la plage est refusée.
Je décale donc le début d'une minute plus tard, ce qui donne une plage de 23 h 59. Le décaler plus tôt aurait donné une plage d'une minute, refusée à cause du point 1.
if (s.getHours() === e.getHours() && s.getMinutes() === e.getMinutes()) {
s = new Date(s.getTime() + 60000);
}
3. Juste après minuit, l'heure et la minute ne suffisent plus
À cause du point 1, une pause qui se termine à 0 h 05 commence à 23 h 50. Envoyé avec seulement l'heure et la minute, ce 23 h 50 était lu comme « 23 h 50 aujourd'hui », et la plage devenait incohérente. Le symptôme : une pause de 5 minutes prise peu après minuit restait ouverte 15 minutes.
Quand le début tombe la veille, et seulement dans ce cas, j'ajoute maintenant l'année, le mois et le jour :
export function winEdge(d: Date, dated: boolean) {
return dated
? { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), hour: d.getHours(), minute: d.getMinutes(), second: 0 }
: { hour: d.getHours(), minute: d.getMinutes(), second: 0 };
}
4. Le nombre d'activités surveillées est limité
minulo gère aussi des créneaux récurrents (l'heure du coucher, les heures de cours…). Si l'on enregistre une activité (DeviceActivityName) par jour de la semaine et par créneau, le compte grimpe vite. Le nombre d'activités surveillées serait plafonné à une vingtaine environ. Au-delà, startMonitoring échoue, et le blocage ne se lève tout simplement jamais à l'heure.
Deux changements :
- un créneau quotidien devient une seule activité, sans
weekday; - les échecs ne sont plus avalés en silence : ils sont enregistrés, pour que l'app puisse signaler qu'il y a un problème.
DA.startMonitoring(FOCUS_ACTIVITY, { intervalStart, intervalEnd, repeats: false }, [])
.catch((x) => set('focus:nativeErr', String(x?.message ?? x).slice(0, 120)));
Le .catch vide est tentant. Ici, il cache exactement le cas où l'utilisateur reste bloqué.
5. « Tout débloquer » débloque aussi ce qui devait rester bloqué
Dans intervalDidEnd, retirer tous les boucliers enlevait aussi d'autres blocages censés rester actifs à ce moment-là : un créneau de coucher, ou une app dont la limite quotidienne était déjà atteinte. Concrètement, un blocage manuel qui se terminait en pleine nuit emportait avec lui le blocage du coucher, qui devait durer jusqu'au matin.
Désormais, l'action de fin réapplique ce qui doit encore être actif à l'heure de fin. La liste est calculée au moment où l'on lance le blocage :
DA.configureActions({ activityName: FOCUS_ACTIVITY, callbackName: 'intervalDidEnd', actions: [
{ type: 'resetBlocks' },
// on réapplique les créneaux encore en cours à l'heure de fin
...during.map((id) => ({ type: 'blockSelection', familyActivitySelectionId: id })),
] });
Côté extension, je filtre aussi les réapplications qui appartiennent à des créneaux déjà terminés, pour qu'un enregistrement répété ne les traîne pas jusqu'au lendemain.
En résumé
- Plages de moins de 15 minutes refusées (sur appareil) : fixer la fin, reculer le début.
- Début et fin avec la même heure et la même minute refusés : décaler le début d'une minute plus tard.
- Après minuit, le début tombe la veille : n'ajouter la date que dans ce cas.
- Nombre d'activités surveillées limité : regrouper les créneaux quotidiens et enregistrer les échecs.
- Tout effacer efface trop : réappliquer, à l'heure de fin, ce qui doit rester.
Les cinq points ont un point commun : DeviceActivitySchedule ne connaît que ce qu'on lui donne dans les DateComponents, et une heure sans date est ambiguë dès qu'on approche de minuit ou de 24 heures. Si vous construisez des plages à partir d'une durée plutôt que d'une heure fixe, je vous conseille de tester les cas « 5 minutes », « 24 heures » et « juste après minuit » dès le début.
À propos de minulo
Masquer les Reels, les Shorts et le fil de TikTok dans minulo est gratuit. On peut aussi bloquer les apps officielles avec Temps d'écran ; en gratuit, c'est jusqu'à deux heures par blocage. Pendant qu'une app officielle est bloquée, ses notifications n'arrivent pas : c'est ainsi que minulo fonctionne. Les messages privés restent accessibles dans minulo.
Si vous avez rencontré d'autres pièges avec DeviceActivity, je suis preneur de vos retours en commentaire.










