A Screen Time API do iOS em 2026: o que a documentação da Apple não conta

FamilyControls, DeviceActivity e extensões de shield, de quem lançou um bloqueador de apps. Por Thomas Willheim · 30 de setembro de 2026

Passei agosto e setembro de 2026 lançando um bloqueador de apps no iOS. Ele usa os frameworks do Tempo de Uso (FamilyControls, DeviceActivity, ManagedSettings e ManagedSettingsUI) a partir do React Native, com o Expo SDK 57 e a biblioteca react-native-device-activity (0.6.1). A documentação da Apple explica o que é cada tipo. Ela diz muito pouco sobre como eles se comportam num celular de verdade, durante a noite, no iOS 26.

Esta é a lista que eu queria ter tido. Comportamentos que vi num aparelho mas não encontrei documentados levam a etiqueta observado; talvez não valham para todas as versões do iOS. O que é específico da biblioteca de React Native leva a etiqueta biblioteca; pule esses itens se você escreve Swift direto.

1. As peças

“A Screen Time API” são quatro frameworks e, na prática, três extensões de app:

O ponto central são as extensões. Quando o tempo de um usuário acaba, meu app geralmente está suspenso ou já foi encerrado, então é a extensão de monitoramento que coloca o shield de volta. Projete como se o processo do seu app nunca estivesse lá quando as coisas acontecem.

2. Entitlements: um pedido por bundle ID

O entitlement Family Controls (Distribution) é aprovado por bundle ID. Com três extensões, são quatro pedidos. Os meus foram:

Depois das aprovações, ative a capability Family Controls (Distribution) em cada App ID no portal de desenvolvedor, crie um App Group e associe aos quatro. Esqueça um e aquele target não vai provisionar. As aprovações não são instantâneas, então faça os quatro pedidos no primeiro dia, antes de escrever muito código.

O arquivo de entitlements de toda extensão acaba igual:

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

No Expo/EAS, o serviço de build só cria provisioning profiles para as extensões que ele conhece. Eu declaro cada target, com seus entitlements, em extra.eas.build.experimental.ios.appExtensions no 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. O App Group é o único canal

Seu app e as extensões dele são processos separados que quase nunca rodam ao mesmo tempo. A única coisa que compartilham é o contêiner do App Group, normalmente via UserDefaults(suiteName:). Tudo de que as extensões precisam (a seleção salva, o texto do shield, o que fazer quando um limite dispara) tem que ser gravado ali com antecedência.

A react-native-device-activity leva isso ao extremo. O app grava uma lista de “ações” numa chave como actions_for_{activity}_{callback}_{event}, e a extensão de monitoramento consulta essa lista quando o callback dispara. Uma consequência útil: dá para mudar o que um monitor já em execução faz sem reiniciá-lo. As extensões também chamam CFPreferencesAppSynchronize antes de ler, para enxergar o que o app gravou por último:

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. Os tokens são opacos, e o seletor

O seletor entrega ao seu app ApplicationTokens, ActivityCategoryTokens e WebDomainTokens, e nada mais. Por design, o app não consegue transformar um token de volta em “Instagram”. O que ele consegue é contá-los, então minha tela de bloqueios mostra “3 apps, 1 categoria”. Esse é todo o modelo de privacidade, e é um bom modelo: meu app literalmente não sabe o que você bloqueou.

O seletor de apps do Tempo de Uso no iOS: uma lista de categorias de apps, com Saúde e Fitness aberta mostrando seus apps
O FamilyActivityPicker da Apple. O usuário vê nomes e ícones dos apps; seu app só recebe tokens opacos.

A extensão de shield é diferente. Ali, o sistema passa um Application do app que está sendo bloqueado, e eu uso o localizedDisplayName dele no texto do shield. O app em si só enxerga tokens.

Duas observações práticas:

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

biblioteca Mais uma: o blockSelection da biblioteca adiciona tokens a uma lista de bloqueio persistida. Depois que o usuário edita a seleção, os apps removidos continuam bloqueados. Eu chamo resetBlocks() primeiro e depois bloqueio de novo a seleção atual, toda vez.

5. Medindo o uso com eventos de limite

O HonorFit dá a você um saldo de minutos que só é descontado enquanto um app bloqueado está na tela. O DeviceActivity não tem uma chamada do tipo “me diga quanto tempo a pessoa usou”; ele tem eventos de limite (thresholds) que disparam quando o uso acumulado de uma seleção chega a uma certa duração. Então eu registro um evento a cada 15 segundos de uso, com o nome em minutos: used-0.25, used-0.5, … até o saldo. Quartos são exatos em ponto flutuante binário, então os nomes voltam intactos pelo Number(). Por baixo da biblioteca, é a API da Apple pura e simples:

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 (o padrão) significa que o uso anterior ao registro dos eventos não conta. Isso importa para o truque de agendamento da próxima seção.

Quando o último evento dispara, a extensão de monitoramento reaplica o shield e posta uma notificação de “tempo esgotado”. Cada evento de 15 segundos atualiza uma única notificação com um identificador fixo, então ela é substituída no lugar e funciona como uma contagem regressiva ao vivo aproximada. interruptionLevel = .passive a entrega em silêncio na Central de Notificações, sem banner.

biblioteca Não use underscores em nomes de eventos ou de atividades. A biblioteca persiste os eventos disparados em events_{activity}_{callback}_{eventName} e depois os lê de volta dividindo a string em _. Um evento chamado used_7 volta como used, e nada nunca bate. O sintoma era um saldo que nunca diminuía.

6. Os agendamentos não começam quando você imagina

Se os seus eventos de limite nunca disparam e não dão nenhum erro, provavelmente é isto.

“Agora” já é passado

observado Um DeviceActivitySchedule sem repetição é comparado com componentes de data, e o monitor roda o próximo intervalo que corresponde. Se você passa o horário atual do relógio como intervalStart, quando o framework avalia o agendamento esse momento já passou. A próxima correspondência é amanhã, e hoje nada dispara: nenhum limite, nenhum callback, nenhum erro. A solução é antecipar o início para que “agora” fique dentro do 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);

Antecipar o início não cobra do usuário a rolagem que ele fez antes, por causa do includesPastActivity: false. Também mantém o intervalo acima do mínimo da Apple: agendamentos com menos de 15 minutos são rejeitados, o que pega quem começa uma sessão pouco antes da meia-noite.

Um agendamento que contém “agora” começa na hora

observado O outro lado da moeda: um agendamento de 00:00 a 23:59:59 contém o horário atual, então começa agora, não na próxima meia-noite. Encontrei duas formas de agendar para amanhã:

observado Não use componentes completos de ano/mês/dia num agendamento para mirar uma data específica. Os callbacks da extensão ficaram instáveis quando tentei, o que bate com relatos nos Apple Developer Forums (tópico 729841). Fique com hora/minuto/segundo.

Uma ressalva: esses comportamentos da meia-noite só aparecem num aparelho, durante a noite. Criei proteções em volta deles, mas não instrumentei tantas meias-noites reais quanto gostaria, então trate esta seção como “o que eu vi”, não como especificação.

7. stopMonitoring dispara intervalDidEnd

observado Chamar stopMonitoring, ou chamar startMonitoring com o nome de um monitor que já está rodando, faz o iOS chamar intervalDidEnd na sua extensão como se o agendamento tivesse terminado naturalmente. Se o seu intervalDidEnd reaplica o shield como rede de segurança (o meu reaplica, para que um monitor não possa expirar e deixar os apps desbloqueados), então toda vez que o app para um monitor, a extensão bloqueia tudo por um instante.

Um usuário relatou o sintoma: o Spotify pausava toda vez que ele abria o HonorFit. Parar o monitor fazia a extensão aplicar o shield na seleção por um momento, o que suspendia o Spotify, antes de o app tirar os shields de novo.

Duas correções, e as duas são necessárias. Primeiro, apague as ações da extensão antes de parar, para que ela não tenha nada para executar:

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

Segundo, dê a toda ação de intervalDidEnd e intervalDidStart um horário de “não antes de” no limite real do agendamento, menos um minuto de folga. A extensão confere isso antes de executar qualquer coisa:

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

Em Swift puro: guarde o horário real de término no App Group e faça o intervalDidEnd desistir quando for chamado antes da hora.

8. Limites fantasmas no iOS 26

No iOS 26.x, o eventDidReachThreshold às vezes dispara sem nenhum uso real: logo depois do agendamento, ou quando um celular que ficou bloqueado por um tempo é pego ou colocado para carregar. A Apple reconheceu o problema em tópicos dos fóruns de desenvolvedores (809410, 811305, 814559). Ele teria sido corrigido num beta do 26.5, mas as pessoas continuaram relatando depois disso. No meu app, o sintoma era “tempo esgotado” depois de o celular ficar no bolso, porque a extensão reaplicava o shield e o saldo ia a zero.

Você não consegue impedir o evento de disparar, mas pode se recusar a acreditar nele. Dois fatos sobre limites genuínos resolvem a maior parte:

  1. O uso não pode correr mais que o relógio. used-7 não pode disparar legitimamente menos de 7 minutos depois de o monitor ser armado. Toda ação de limite carrega neverTriggerBefore = armedAt + N minutes − 5s, então a extensão ignora disparos impossivelmente cedo.
  2. Uso real chega aos poucos; fantasmas chegam de uma vez. Quando o app lê os eventos disparados, ele trata o conjunto como fantasma quando o limite mais baixo e o mais alto dispararam com no máximo uns 3 segundos de diferença, mas cobrem um minuto ou mais de uso.
// 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
}

A proteção 2 roda no app, não na extensão, então um fantasma ainda pode levantar o shield. Mas o saldo sobrevive, e na próxima vez que o app é aberto ele tira o shield de novo. Detectar as rajadas dentro da extensão exigiria Swift personalizado, que ainda não escrevi.

observado Um problema relacionado vai no sentido contrário: limites que deveriam disparar e não disparam. Com uso entra-e-sai no iOS 26, vi o primeiro limite de 15 segundos de um monitor novo disparar na hora certa e os seguintes serem agrupados ou simplesmente descartados (used-0.25 disparou, used-0.5 em diante nunca). Minha solução é tratar como velho qualquer monitor que já tenha medido uso e armar um novo, sobre o saldo restante, toda vez que o app vai para segundo plano. Isso perdoa até 15 segundos de uso a cada reinício, o que para mim tudo bem.

9. Um shield não consegue abrir o seu app

O design óbvio é um botão no shield dizendo “Ganhar mais tempo” que abre o seu app. Não existe forma suportada de fazer isso; o DTS da Apple diz que não há API para uma extensão de shield abrir o app principal. Um ShieldActionDelegate responde a um toque de botão com um ShieldActionResponse (.none, .close ou .defer), e nenhum deles abre nada.

observado O truque que você vai encontrar (NSExtensionContext().open(url) a partir da extensão ShieldAction, que a biblioteca expõe como openUrl) não fez nada no iOS atual quando testei num aparelho em setembro de 2026. O botão só fechava o shield. Postar uma notificação a partir do toque no botão também não foi confiável para mim.

O que eu uso no lugar: o shield tem um único botão “Fechar”, e a extensão ShieldConfiguration posta uma notificação local sempre que o shield é exibido. O usuário toca no banner e cai dentro do app. O app grava o conteúdo da notificação no App Group com antecedência e dispensa a notificação quando volta para o primeiro plano. O iOS pode pedir a configuração do shield várias vezes por exibição, então o envio tem 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
}

É uma gambiarra, e depende de o usuário permitir notificações, mas foi o único caminho até o app que funcionou de forma confiável para mim.

10. Mantendo extensões personalizadas no prebuild

biblioteca Se você usa Expo, o expo prebuild roda o config plugin da react-native-device-activity, que por padrão copia os fontes originais das extensões da biblioteca para ./targets a cada execução. Minha ShieldConfiguration personalizada foi substituída sem nenhum aviso. A solução é uma opção do plugin:

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

O custo: quando você atualizar a biblioteca, compare na mão os targets/ e o Swift compartilhado dela com os seus e reaplique suas mudanças.

11. Xcode 26, o Simulador e os testes

12. E no Android

Para comparar: o Android não tem um equivalente oficial, então você constrói tudo por conta própria. O bloqueador do HonorFit no Android é um serviço em primeiro plano (do tipo specialUse, já que bloquear apps não se encaixa em nenhum dos tipos listados) que consulta o UsageStatsManager uma vez por segundo enquanto a tela está ligada, para saber qual app está na frente. Quando um app bloqueado está aberto e o saldo é zero, ele desenha por cima uma tela de bloqueio TYPE_APPLICATION_OVERLAY (a permissão “Sobrepor a outros apps”). Um receiver de inicialização e um alarme de watchdog reiniciam o serviço depois de reinicializações, atualizações do app e gerenciadores de bateria agressivos de fabricantes.

Os prós e contras apontam para o lado oposto do iOS. Você vê os nomes de pacote reais, então a privacidade é uma promessa que você cumpre, e não uma que o sistema garante. Um app bloqueado pode ficar visível por até um intervalo de consulta, e ele continua rodando por baixo da sobreposição. E você vai escrever declarações no Play Console para o tipo de serviço em primeiro plano, a permissão de sobreposição e a visibilidade de pacotes. Evitei de propósito um AccessibilityService, que passa pela análise mais rigorosa de todas.

13. Checklist

SintomaCausa provávelSolução
O provisionamento falha para um targetFalta o entitlement ou o App Group naquele bundle IDPeça Family Controls (Distribution) e associe o App Group em todos os bundle IDs
Nenhum evento de limite, nuncaintervalStart = agora, então o monitor fica agendado para amanhãAntecipe o início; mantenha o intervalo ≥ 15 min
Um monitor feito para amanhã roda hojeO agendamento contém o horário atualAgendamento já expirado hoje [00:00, T), ou repeats: true com ações protegidas
Os apps piscam bloqueados quando o seu app abrestopMonitoring dispara intervalDidEndLimpe as ações antes de parar; proteja com um horário de “não antes de”
“Tempo esgotado” depois de o celular ficar paradoLimites fantasmas do iOS 26Proteção pelo relógio mais filtro de rajadas
Limites posteriores nunca disparamDescartados/agrupados com uso intermitenteArme um monitor novo sempre que o app for para segundo plano
O botão do shield não abre o appNão há API suportadaPoste uma notificação a partir da ShieldConfiguration quando o shield aparecer
Os eventos disparados não batem (RN)Underscore no nome do eventoUse used-7, não used_7
O seletor abre vazio (RN)Falta a prop includeEntireCategoryPasse a prop, com qualquer valor

14. Respostas rápidas

Preciso do entitlement Family Controls para cada extensão?

Sim. Ele é concedido por bundle ID. O app mais as extensões DeviceActivityMonitor, ShieldConfiguration e ShieldAction são quatro aprovações, e cada App ID também precisa do App Group compartilhado.

Por que o eventDidReachThreshold do DeviceActivity não dispara?

No meu caso, geralmente era um agendamento sem repetição começando “agora”, que acaba agendado para amanhã. Antecipe o intervalStart. No iOS 26, com uso entra-e-sai, limites posteriores de menos de um minuto também eram descartados; armar um monitor novo ajudou.

Uma extensão ShieldAction ou ShieldConfiguration consegue abrir o app principal?

Não com uma API suportada, e o truque do NSExtensionContext não fez nada no iOS atual nos meus testes. O que uso no app é uma notificação local postada pela ShieldConfiguration quando o shield é exibido.

Dá para testar a Screen Time API no Simulador do iOS?

Em parte. O pedido de autorização e o seletor aparecem nos simuladores do iOS 26, mas a autorização não terminou, o seletor não tinha nada para listar e a extensão de monitoramento nunca rodou. Use um aparelho.

15. Por que eu precisei de tudo isso

Eu criei o HonorFit, um bloqueador de apps em que você ganha tempo de tela fazendo flexões. Ele funciona no sistema de honra: você digita suas repetições, e não tem câmera, nem IA, nem chamadas de rede. Tudo acima foi o que precisei para fazer “seus minutos conquistados só são descontados enquanto um app bloqueado está na tela” funcionar num iPhone. Se quiser ver o app rodando, ele está na App Store e no Google Play. Se você chegou aqui porque quer um app assim, e não construir um, escrevi sobre como funcionam os bloqueadores de apps com flexões (em inglês) e um apanhado honesto dos que existem, concorrentes incluídos. Se você topou com algum comportamento do Tempo de Uso que não está aqui, ou descobriu que algo acima está errado num iOS mais novo, eu gostaria de saber.

Continue lendo