DeviceActivity no iOS: as cinco armadilhas de horário de um bloqueio com tempo
Oi, sou o Ayumu Kitabayashi e desenvolvo sozinho o minulo, um app de iPhone que esconde os Reels do Instagram, os Shorts do YouTube e o "Para você" do TikTok, mas mantém as DMs. O app é feito com Expo / React Native. Na parte de Tempo de Uso, uso a biblioteca react-native-device-activity, do Robert Herber, mais um pouco de código meu.
O minulo tem um bloqueio com tempo: você trava o app oficial do Instagram com o Tempo de Uso por, digamos, 30 minutos, e ele precisa se destravar sozinho na hora marcada, mesmo com o minulo fechado. Tropecei cinco vezes, e sempre na mesma coisa: o jeito como o iOS interpreta o começo e o fim de um intervalo de horário.
O código abaixo é TypeScript, mas por baixo ele chama DeviceActivityCenter.startMonitoring com um DeviceActivitySchedule. Ou seja, os mesmos problemas aparecem se você escrever direto em Swift.
A ideia
- Quando o bloqueio começa, o
ManagedSettingsStoreaplica o escudo (shield) no app oficial. - Na hora de fim, o bloqueio precisa ser removido, com o app rodando ou não.
Para o item 2, o app não pode contar consigo mesmo: ele pode estar suspenso. Então reservamos um intervalo intervalStart–intervalEnd num DeviceActivitySchedule, e quem remove o bloqueio é a extensão DeviceActivityMonitor, em intervalDidEnd.
1. Intervalos menores que 15 minutos são recusados
Se você passa um intervalo de 5 minutos para startMonitoring, recebe um erro. Nos meus testes no aparelho, tudo abaixo de 15 minutos foi recusado.
Meu primeiro contorno foi "agora + 15 minutos". Resultado: uma pausa de 5 minutos ficava aberta por 15. O certo é manter o fim fixo e puxar o começo para trás:
// mantém o fim; puxa o começo para trás até o intervalo ter 15 minutos
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);
// se o começo cair no dia anterior, inclui a data (ver o item 3)
return { s, e, dated: s.getDate() !== now.getDate() };
}
O fim é arredondado para o minuto seguinte. O começo acaba ficando um pouco no passado; o que importa para mim é que o intervalDidEnd chegue na hora do fim.
2. Mesma hora e mesmo minuto no começo e no fim: recusado
Com DateComponents que só têm hora e minuto, um bloqueio de 24 horas (das 13h05 de hoje às 13h05 de amanhã) é lido como "começo = fim", e o intervalo é recusado.
Por isso, empurro o começo um minuto para depois, o que dá um intervalo de 23h59. Puxar para antes daria um intervalo de 1 minuto, recusado por causa do item 1.
if (s.getHours() === e.getHours() && s.getMinutes() === e.getMinutes()) {
s = new Date(s.getTime() + 60000);
}
3. Logo depois da meia-noite, hora e minuto não bastam
Por causa do item 1, uma pausa que termina às 0h05 começa às 23h50. Enviado só com hora e minuto, esse 23h50 era lido como "23h50 de hoje", e o intervalo ficava incoerente. O sintoma: uma pausa de 5 minutos tirada logo depois da meia-noite ficava aberta por 15 minutos.
Quando o começo cai no dia anterior, e só nesse caso, agora incluo ano, mês e dia:
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. O número de atividades monitoradas é limitado
O minulo também tem horários que se repetem (hora de dormir, horário de aula…). Se você registra uma atividade (DeviceActivityName) por dia da semana e por horário, a conta sobe rápido. O número de atividades monitoradas teria um teto de mais ou menos vinte. Passou disso, o startMonitoring falha, e o bloqueio simplesmente nunca é removido na hora.
Duas mudanças:
- um horário diário virou uma única atividade, sem
weekday; - as falhas não são mais engolidas em silêncio: ficam registradas, para o app poder avisar que algo deu errado.
DA.startMonitoring(FOCUS_ACTIVITY, { intervalStart, intervalEnd, repeats: false }, [])
.catch((x) => set('focus:nativeErr', String(x?.message ?? x).slice(0, 120)));
O .catch vazio é tentador. Aqui, ele esconde justamente o caso em que o usuário fica preso no bloqueio.
5. "Desbloquear tudo" desbloqueia também o que devia continuar bloqueado
No intervalDidEnd, remover todos os escudos removia também outros bloqueios que deviam continuar ativos naquele momento: um horário de dormir, ou um app cujo limite diário já tinha estourado. Na prática, um bloqueio manual que terminava de madrugada levava junto o bloqueio da hora de dormir, que devia durar até de manhã.
Agora, a ação de fim reaplica o que ainda precisa estar ativo na hora do fim. A lista é calculada no momento em que o bloqueio começa:
DA.configureActions({ activityName: FOCUS_ACTIVITY, callbackName: 'intervalDidEnd', actions: [
{ type: 'resetBlocks' },
// reaplica os horários que ainda estão valendo na hora do fim
...during.map((id) => ({ type: 'blockSelection', familyActivitySelectionId: id })),
] });
Na extensão, também filtro as reaplicações de horários que já terminaram, para que um registro repetido não as arraste até o dia seguinte.
Resumo
- Intervalos menores que 15 minutos são recusados (no aparelho): fixe o fim e puxe o começo para trás.
- Começo e fim com a mesma hora e o mesmo minuto são recusados: empurre o começo um minuto para depois.
- Depois da meia-noite, o começo cai no dia anterior: inclua a data só nesse caso.
- O número de atividades monitoradas é limitado: agrupe os horários diários e registre as falhas.
- Limpar tudo limpa demais: na hora do fim, reaplique o que deve continuar.
Os cinco têm algo em comum: o DeviceActivitySchedule só sabe o que você passa nos DateComponents, e uma hora sem data fica ambígua perto da meia-noite ou das 24 horas. Se você monta intervalos a partir de uma duração em vez de um horário fixo, recomendo testar logo de cara os casos "5 minutos", "24 horas" e "logo depois da meia-noite".
Sobre o minulo
Esconder Reels, Shorts e o feed do TikTok no minulo é grátis. Também dá para bloquear os apps oficiais com o Tempo de Uso; no grátis, até duas horas por bloqueio. Enquanto um app oficial está bloqueado, as notificações dele não chegam: o minulo funciona assim. As DMs continuam acessíveis dentro do minulo.
Se você já caiu em outras armadilhas com o DeviceActivity, conta aí nos comentários.



