La Screen Time API de iOS en 2026: lo que la documentación de Apple no te cuenta

FamilyControls, DeviceActivity y las extensiones de shield, desde un bloqueador de apps publicado. Por Thomas Willheim · 30 de septiembre de 2026

Pasé agosto y septiembre de 2026 sacando un bloqueador de apps en iOS. Usa los frameworks de Tiempo de uso (FamilyControls, DeviceActivity, ManagedSettings y ManagedSettingsUI) desde React Native, con Expo SDK 57 y la biblioteca react-native-device-activity (0.6.1). La documentación de Apple te dice qué es cada tipo. Dice muy poco sobre cómo se comportan en un teléfono real, durante la noche, en iOS 26.

Esta es la lista que me hubiera gustado tener. Lo que vi en un dispositivo pero no encuentro documentado lleva la etiqueta observado; puede que no se cumpla en todas las versiones de iOS. Lo que es específico de la biblioteca de React Native lleva la etiqueta biblioteca; sáltatelo si escribes Swift directamente.

1. Las piezas

«La Screen Time API» son cuatro frameworks y, en la práctica, tres extensiones de app:

Lo importante son las extensiones. Cuando a un usuario se le acaba el tiempo, mi app suele estar suspendida o cerrada, así que es la extensión del monitor la que vuelve a poner el shield. Diseña como si el proceso de tu app nunca fuera a estar ahí cuando pasan las cosas.

2. Entitlements: una solicitud por bundle ID

El entitlement Family Controls (Distribution) se aprueba por bundle ID. Con tres extensiones, son cuatro solicitudes. Las mías fueron:

Cuando estén aprobadas, activa la capability Family Controls (Distribution) en cada App ID en el portal de desarrolladores, y luego crea un App Group y asígnalo a los cuatro. Si te falta uno, ese target no se podrá aprovisionar. Las aprobaciones no son instantáneas, así que envía las cuatro solicitudes el primer día, antes de haber escrito mucho código.

El archivo de entitlements de cada extensión termina siendo el mismo:

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

En Expo/EAS, el servicio de compilación solo crea perfiles de aprovisionamiento para las extensiones que conoce. Declaro cada target, con sus entitlements, en extra.eas.build.experimental.ios.appExtensions dentro de 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. El App Group es el único canal

Tu app y sus extensiones son procesos separados que casi nunca se ejecutan al mismo tiempo. Lo único que comparten es el contenedor del App Group, normalmente como UserDefaults(suiteName:). Todo lo que necesitan las extensiones (la selección guardada, el texto del shield, qué hacer cuando se dispara un umbral) tiene que estar escrito ahí de antemano.

react-native-device-activity lleva esto hasta el final. La app escribe una lista de «acciones» bajo una clave como actions_for_{activity}_{callback}_{event}, y la extensión del monitor las busca cuando se dispara el callback. Una consecuencia útil: puedes cambiar lo que hace un monitor que ya está corriendo sin reiniciarlo. Las extensiones también llaman a CFPreferencesAppSynchronize antes de leer, así ven lo último que escribió la app:

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. Los tokens son opacos, y el selector

El selector le entrega a tu app ApplicationTokens, ActivityCategoryTokens y WebDomainTokens, y nada más. Por diseño, la app no puede convertir un token de vuelta en «Instagram». Lo que sí puede hacer es contarlos, así que mi pantalla de bloqueos muestra «3 apps, 1 categoría». Ese es todo el modelo de privacidad, y es bueno: mi app literalmente no sabe qué bloqueaste.

El selector de apps de Tiempo de uso en iOS: una lista de categorías de apps, con Salud y forma física desplegada para mostrar sus apps
El FamilyActivityPicker de Apple. El usuario ve los nombres y los iconos de las apps; tu app solo recibe tokens opacos.

La extensión del shield es distinta. Ahí, el sistema te pasa un Application de la app a la que se le pone el shield, y yo uso su localizedDisplayName en el texto del shield. La app en sí solo ve tokens.

Dos notas prácticas:

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

biblioteca Una más: blockSelection de la biblioteca agrega tokens a una lista de bloqueo persistente. Cuando el usuario edita su selección, las apps que quitó siguen con el shield puesto. Yo llamo primero a resetBlocks() y luego vuelvo a bloquear la selección actual, cada vez.

5. Medir el uso con eventos de umbral

HonorFit te da un saldo de minutos que solo se descuenta mientras una app bloqueada está en pantalla. DeviceActivity no tiene una llamada de «dime cuánto la usaron»; tiene eventos de umbral que se disparan una vez cuando el uso acumulado de una selección llega a una duración. Así que registro un evento por cada 15 segundos de uso, con nombres en minutos: used-0.25, used-0.5, … hasta el saldo. Los cuartos son exactos en punto flotante binario, así que los nombres van y vuelven sin cambios a través de Number(). Debajo de la biblioteca, esto es la API normal de Apple:

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 (el valor por defecto) significa que el uso anterior al registro de los eventos no cuenta. Eso importa para el truco del horario de la siguiente sección.

Cuando se dispara el último evento, la extensión del monitor vuelve a aplicar el shield y publica una notificación de «se acabó el tiempo». Cada evento de 15 segundos actualiza una única notificación con un identificador fijo, así que se reemplaza en el mismo lugar y funciona como una cuenta atrás en vivo aproximada. interruptionLevel = .passive la entrega en silencio al Centro de notificaciones, sin banner.

biblioteca No pongas guiones bajos en los nombres de eventos ni de actividades. La biblioteca guarda los eventos disparados bajo events_{activity}_{callback}_{eventName} y los vuelve a leer separando por _. Un evento llamado used_7 vuelve como used, y nunca coincide nada. El síntoma era un saldo que nunca bajaba.

6. Los horarios no empiezan cuando crees

Si tus eventos de umbral nunca se disparan y no hay ningún error, probablemente sea esto.

«Ahora» ya es pasado

observado Un DeviceActivitySchedule sin repetición se compara con componentes de fecha, y el monitor ejecuta el siguiente intervalo que coincida. Si pasas la hora actual del reloj como intervalStart, para cuando el framework lo evalúa ese momento ya pasó. La siguiente coincidencia es mañana, y hoy no se dispara nada: ni umbrales, ni callbacks, ni errores. La solución es adelantar el inicio hacia atrás para que «ahora» quede dentro del intervalo:

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

Mover el inicio hacia atrás no puede cobrarle al usuario el scroll anterior, gracias a includesPastActivity: false. Además mantiene el intervalo por encima del mínimo de Apple: los horarios de menos de 15 minutos se rechazan, y eso le pega a cualquiera que empiece una sesión justo antes de la medianoche.

Un horario que contiene «ahora» empieza de inmediato

observado La otra cara: un horario de 00:00–23:59:59 contiene la hora actual, así que empieza ahora, no en la próxima medianoche. Encontré dos formas de reservar el día siguiente:

observado No uses componentes completos de año/mes/día en un horario para apuntar a una fecha concreta. Los callbacks de la extensión se volvieron poco fiables cuando lo intenté, lo que coincide con reportes en los Apple Developer Forums (hilo 729841). Quédate con hora/minuto/segundo.

Una advertencia: estos comportamientos de medianoche solo aparecen en un dispositivo, durante la noche. Construí protecciones alrededor de ellos, pero no he instrumentado tantas medianoches reales como me gustaría, así que toma esta sección como «lo que vi», no como una especificación.

7. stopMonitoring dispara intervalDidEnd

observado Llamar a stopMonitoring, o llamar a startMonitoring con el nombre de un monitor que ya está corriendo, hace que iOS llame a intervalDidEnd en tu extensión como si el horario hubiera terminado de forma natural. Si tu intervalDidEnd vuelve a poner el shield como red de seguridad (el mío lo hace, para que un monitor no pueda vencer y dejar las apps desbloqueadas), entonces cada vez que la app detiene un monitor, la extensión le pone el shield a todo por un instante.

Un usuario reportó el síntoma: Spotify se pausaba cada vez que abría HonorFit. Detener el monitor hacía que la extensión le pusiera el shield a la selección por un momento, lo que suspendía Spotify, antes de que la app volviera a quitar los shields.

Dos arreglos, y hacen falta los dos. Primero, borra las acciones de la extensión antes de detenerla, para que no tenga nada que ejecutar:

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

Segundo, dale a cada acción de intervalDidEnd e intervalDidStart una marca de tiempo de «no antes de» en el límite real del horario, menos un minuto de margen. La extensión la revisa antes de ejecutar nada:

// 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 puro: guarda la hora de fin real en el App Group y haz que intervalDidEnd no haga nada si lo llaman antes de tiempo.

8. Umbrales fantasma en iOS 26

En iOS 26.x, eventDidReachThreshold a veces se dispara sin ningún uso real: justo después de programarlo, o cuando alguien agarra o conecta un teléfono que estuvo un rato bloqueado. Apple lo ha reconocido en hilos de los foros para desarrolladores (809410, 811305, 814559). Según se informó, se corrigió en una beta de 26.5, pero la gente lo siguió reportando después. En mi app, el síntoma era «sin tiempo» después de tener el teléfono en el bolsillo, porque la extensión volvía a poner el shield y el saldo bajaba a cero.

No puedes impedir que el evento se dispare, pero sí puedes negarte a creerle. Dos hechos sobre los umbrales genuinos hacen casi todo el trabajo:

  1. El uso no puede ir más rápido que el reloj. used-7 no puede dispararse legítimamente menos de 7 minutos después de que se armó el monitor. Cada acción de umbral lleva neverTriggerBefore = armedAt + N minutes − 5s, así que la extensión ignora los disparos imposiblemente tempranos.
  2. El uso real llega de a poco; los fantasmas llegan de golpe. Cuando la app lee los eventos disparados, los trata como fantasma si el umbral más bajo y el más alto se dispararon con unos 3 segundos de diferencia o menos, pero abarcan un minuto de uso o más.
// 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
}

La protección 2 corre en la app, no en la extensión, así que un fantasma todavía puede poner el shield. Pero el saldo sobrevive, y la próxima vez que se abre la app, quita el shield. Detectar las ráfagas dentro de la extensión requeriría Swift personalizado, que todavía no he escrito.

observado Hay un problema relacionado que va en sentido contrario: umbrales que deberían dispararse y no lo hacen. Con un uso de entrar y salir en iOS 26, vi que el primer umbral de 15 segundos de un monitor nuevo se disparaba a tiempo, y que los siguientes se agrupaban o se perdían por completo (used-0.25 se disparó, used-0.5 en adelante nunca). Mi solución es tratar cualquier monitor que ya midió uso como obsoleto y armar uno nuevo, sobre el saldo restante, cada vez que la app pasa a segundo plano. Eso perdona hasta 15 segundos de uso por reinicio, y me parece bien.

9. Un shield no puede abrir tu app

El diseño obvio es un botón en el shield que diga «Gana más tiempo» y abra tu app. No hay una forma compatible de hacerlo; Apple DTS dice que no existe ninguna API para que una extensión de shield abra su app principal. Un ShieldActionDelegate responde a un toque de botón con un ShieldActionResponse (.none, .close o .defer), y ninguno de esos abre nada.

observado El truco que vas a encontrar (NSExtensionContext().open(url) desde la extensión ShieldAction, que la biblioteca expone como openUrl) no hizo nada en el iOS actual cuando lo probé en un dispositivo en septiembre de 2026. El botón simplemente cerraba el shield. Publicar una notificación al tocar el botón tampoco me resultó fiable.

Lo que uso en su lugar: el shield tiene un solo botón de «Cerrar», y la extensión ShieldConfiguration publica una notificación local cada vez que el shield se muestra. El usuario toca el banner y llega a la app. La app escribe de antemano el contenido de la notificación en el App Group, y la descarta cuando pasa a primer plano. iOS puede pedir la configuración del shield varias veces por cada vez que se muestra, así que la publicación tiene 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
}

Es un parche, y depende de que el usuario permita las notificaciones, pero es la única vía hacia la app que me funcionó de forma fiable.

10. Conservar extensiones personalizadas al hacer prebuild

biblioteca Si usas Expo, expo prebuild ejecuta el config plugin de react-native-device-activity, que por defecto copia en ./targets las fuentes de las extensiones originales de la biblioteca en cada ejecución. Mi ShieldConfiguration personalizada se reemplazó sin avisar. La solución es una opción del plugin:

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

El costo: cuando actualices la biblioteca, compara a mano su targets/ y su Swift compartido con los tuyos, y vuelve a aplicar tus cambios.

11. Xcode 26, el Simulador y las pruebas

12. Y en Android

Para contrastar: Android no tiene un equivalente oficial, así que lo construyes tú. El bloqueador de HonorFit en Android es un servicio en primer plano (de tipo specialUse, porque bloquear apps no encaja en ninguno de los tipos de la lista) que consulta UsageStatsManager una vez por segundo mientras la pantalla está encendida para saber qué app está al frente. Cuando hay una app bloqueada en pantalla y el saldo es cero, dibuja encima una pantalla de bloqueo TYPE_APPLICATION_OVERLAY («Mostrar sobre otras apps»). Un receptor de arranque y una alarma de vigilancia reinician el servicio después de reinicios, actualizaciones de la app y gestores de batería agresivos de los fabricantes.

Las concesiones van en sentido contrario a las de iOS. Ves los nombres de paquete reales, así que la privacidad es una promesa que tú cumples, no una que impone el sistema operativo. Una app bloqueada puede quedar visible hasta un intervalo de consulta, y sigue ejecutándose debajo de la capa. Y te va a tocar escribir declaraciones en Play Console para el tipo de servicio en primer plano, el permiso de superposición y la visibilidad de paquetes. Evité a propósito un AccessibilityService, que pasa por la revisión más estricta de todas.

13. Lista de verificación

SíntomaCausa probableSolución
Falla el aprovisionamiento de un targetFalta el entitlement o el App Group en ese bundle IDSolicita Family Controls (Distribution) y asigna el App Group para cada bundle ID
Nunca llega ningún evento de umbralintervalStart = ahora, así que el monitor queda reservado para mañanaMueve el inicio hacia atrás; mantén el intervalo ≥ 15 min
Un monitor pensado para mañana corre hoyEl horario contiene la hora actualHorario vencido hoy [00:00, T), o repeats: true con acciones protegidas
Las apps reciben un shield fugaz cuando abres tu appstopMonitoring dispara intervalDidEndBorra las acciones antes de detener; protege con una hora de «no antes de»
«Sin tiempo» después de que el teléfono estuvo sin usarUmbrales fantasma de iOS 26Protección por reloj más filtro de ráfagas
Los umbrales posteriores nunca se disparanSe pierden o se agrupan con el uso intermitenteArma un monitor nuevo cada vez que la app pasa a segundo plano
El botón del shield no abre la appNo hay API compatiblePublica una notificación desde ShieldConfiguration al mostrarse
Los eventos disparados no coinciden (RN)Guion bajo en el nombre del eventoUsa used-7, no used_7
El selector se abre vacío (RN)Falta la prop includeEntireCategoryPásala, con cualquier valor

14. Respuestas rápidas

¿Necesito el entitlement de Family Controls para cada extensión?

Sí. Se concede por bundle ID. La app más una extensión DeviceActivityMonitor, una ShieldConfiguration y una ShieldAction son cuatro aprobaciones, y cada App ID necesita además el App Group compartido.

¿Por qué no se dispara eventDidReachThreshold de DeviceActivity?

En mi caso solía ser un horario sin repetición que empezaba «ahora», y que queda reservado para mañana. Mueve intervalStart hacia atrás. En iOS 26, con uso de entrar y salir, también se perdían umbrales posteriores de menos de un minuto; volver a armar un monitor nuevo ayudó.

¿Puede una extensión ShieldAction o ShieldConfiguration abrir la app principal?

No con una API compatible, y en mis pruebas el truco de NSExtensionContext no hizo nada en el iOS actual. Lo que uso es una notificación local publicada desde ShieldConfiguration cuando se muestra el shield.

¿Puedo probar la Screen Time API en el Simulador de iOS?

En parte. La solicitud y el selector aparecen en los simuladores de iOS 26, pero la autorización no se completó, el selector no tenía nada que listar y la extensión del monitor nunca se ejecutó. Usa un dispositivo.

15. Por qué necesité todo esto

Construí HonorFit, un bloqueador de apps en el que te ganas el tiempo de pantalla haciendo flexiones. Funciona con el sistema de honor: escribes tus repeticiones, y no hay cámara, ni IA, ni llamadas de red. Todo lo de arriba es lo que hizo falta para que «tus minutos ganados solo se descuentan mientras una app bloqueada está en pantalla» funcionara en un iPhone. Si quieres verlo funcionando, está en el App Store y en Google Play. Si llegaste aquí porque quieres una app así y no construirla, escribí cómo funcionan los bloqueadores de apps con flexiones y un repaso honesto de los que existen, competidores incluidos. Si te topaste con un comportamiento de Tiempo de uso que no está aquí, o viste que algo de lo de arriba ya no es así en un iOS más nuevo, me gustaría saberlo.

Sigue leyendo