Como criar um bloqueador de apps para Android sem AccessibilityService
Eu faço o HonorFit, um bloqueador de apps em que você ganha tempo de tela fazendo flexões (sistema de honra, sem câmera). A primeira versão para Android bloqueava apps do jeito que a maioria dos bloqueadores de Android faz: com um AccessibilityService. Em agosto eu arranquei isso fora e troquei por UsageStatsManager, um serviço em primeiro plano e uma janela de sobreposição. O bloqueio ficou um pouco mais brando, o onboarding ficou bem menos assustador e a revisão do Play ficou bem mais simples.
É assim que essa arquitetura funciona, e estas são as coisas que me pegaram no caminho. Todo o código é Kotlin dentro de um módulo local do Expo, mas nada aqui é específico de React Native.
Por que abandonar a API de acessibilidade
Um serviço de acessibilidade é a ferramenta mais poderosa que você tem no Android. Você fica sabendo de cada mudança de janela no instante em que ela acontece, e performGlobalAction(GLOBAL_ACTION_HOME) tira o usuário de um app bloqueado na hora. É por isso que tantos bloqueadores usam. Os custos:
- O pedido de permissão é assustador. O Android avisa ao usuário que o app pode ver tudo o que está na tela e agir em nome dele. Para um app cuja proposta é “nada sai do seu celular”, pedir isso no primeiro dia pega muito mal, e muita gente desiste ali mesmo.
- O Google Play trata isso como uma API sensível. Se você não é uma ferramenta de acessibilidade, precisa de um aviso em destaque dentro do app, de uma declaração no Play Console e de um revisor que concorde que o seu caso de uso é aceitável. Eu tinha o modal do aviso pronto. Prefiro não precisar dele.
- Ele enxerga muito mais do que um bloqueador precisa. Um bloqueador precisa de exatamente uma informação: qual app está em primeiro plano.
Essa informação está disponível em outro lugar: nos eventos de uso que alimentam o Bem-estar digital.
O formato geral
Três permissões, todas concedidas pelo usuário nas Configurações:
| Permissão | Para que serve |
|---|---|
PACKAGE_USAGE_STATS (“Acesso ao uso”) | Ler UsageEvents para saber qual app está na frente. Nunca é concedida em tempo de execução; você manda o usuário para a tela de Configurações e verifica o app-op. |
SYSTEM_ALERT_WINDOW (“Sobrepor a outros apps”) | Desenhar a tela de bloqueio por cima de um app bloqueado. |
FOREGROUND_SERVICE_SPECIAL_USE | O serviço de monitoramento. Bloquear apps não se encaixa em nenhum dos tipos de serviço em primeiro plano da lista, então é specialUse com uma string de subtipo explicando o porquê. |
As peças:
ForegroundMonitorServicesó roda enquanto há algo para fazer valer (uma lista de bloqueio não vazia e o acesso ao uso concedido). Uma vez por segundo, e só com a tela ligada, ele pergunta aoUsageStatsManagero que veio para o primeiro plano. Um app bloqueado com tempo sobrando vai consumindo o saldo guardado; quando chega a zero, ele mostra a sobreposição.- A notificação obrigatória dele é o mostrador da cota (“faltam 12 min”), então aquilo que o Android te obriga a ter faz um trabalho de verdade.
- Todo o estado fica no
SharedPreferences, não no JS. O serviço tem que continuar funcionando quando o runtime do React Native está morto, que é quase sempre. O lado do JS deposita tempo e lê o saldo pelo módulo; quem cuida do bloqueio é o serviço.
A entrada do serviço no manifesto:
<service
android:name=".ForegroundMonitorService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="User-configured app blocking for digital wellbeing: monitors
which app is in the foreground (UsageStats) so apps the user chose to block
can be locked when their earned screen time runs out." />
</service>
Você também precisa justificar o specialUse na declaração de serviços em primeiro plano do Play Console. Contar a mesma história nos dois lugares ajuda.
Consultando o UsageStats (e o atraso de gravação)
O loop principal é pequeno. Percorra os eventos desde a última consulta e guarde o último pacote com MOVE_TO_FOREGROUND (é a mesma constante que ACTIVITY_RESUMED, renomeada na API 29):
val end = System.currentTimeMillis()
val events = usm.queryEvents(windowStart, end)
val event = UsageEvents.Event()
while (events.hasNextEvent()) {
events.getNextEvent(event)
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
currentForeground = event.packageName
}
}
// Na próxima vez, varrer de novo os últimos 15 s: veja abaixo.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)
Uma vantagem em relação aos eventos de acessibilidade: o teclado e a aba de notificações não são activities, então nunca aparecem como saídas falsas do tipo “o usuário saiu do app”.
Pegadinha: os eventos chegam atrasados
Minha primeira versão movia a janela estritamente para a frente: cada consulta pedia [lastEnd, now]. Funcionava quase sempre, até que num celular recém-ligado a primeira abertura de um app bloqueado nunca era detectada. O UsageStats grava os eventos no armazenamento com atraso, então um evento com horário 10:00:00.900 pode aparecer depois de uma consulta que já cobriu 10:00:01. Uma janela que só avança pula esse evento para sempre.
A solução: toda consulta varre de novo uma sobreposição para trás (eu uso 15 segundos). Ver o mesmo evento duas vezes não faz mal, já que o último resume da janela ganha de qualquer jeito. Um evento que chegue com mais de 15 segundos de atraso ainda se perde, mas eu nunca vi um.
Pegadinha: a primeira consulta precisa olhar bem para trás
Quando o serviço começa (depois de um reinício, ou quando a tela liga) não existe um “último app conhecido”. Se o usuário está no TikTok há 20 minutos e a primeira consulta só olha um minuto para trás, ela não vê nada até ele trocar de app. Então a primeira consulta varre a última hora. Isso é seguro porque o launcher também é uma activity: o que fez resume por último é mesmo o que está na frente.
Pegadinha: acesso revogado parece silêncio
Se o usuário desliga o acesso ao uso enquanto o serviço está rodando, queryEvents não lança exceção. Ele só não retorna nada. Sua última leitura (“o Instagram está na frente”) fica velha e o saldo vai sendo consumido para sempre. Então toda consulta verifica o app-op primeiro e para o relógio se ele sumiu:
fun hasUsageAccess(context: Context): Boolean {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode = appOps.checkOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
return mode == AppOpsManager.MODE_ALLOWED
}
Tela desligada quer dizer parar
Quando a tela desliga, o serviço contabiliza a sessão aberta e para de consultar de vez. Ninguém está usando um app com a tela desligada, e um loop de 1 Hz que só roda enquanto você está olhando o celular sai barato. A primeira consulta depois que a tela liga também cuida da virada do dia (o reset da meia-noite, a base diária), então isso acontece antes de o usuário chegar a um app bloqueado, mesmo que ele nunca abra o HonorFit.
A sobreposição de bloqueio
Sem acessibilidade não existe GLOBAL_ACTION_HOME, então você não consegue expulsar o usuário de um app. Em vez disso, você cobre o app: uma janela TYPE_APPLICATION_OVERLAY com um botão de “ganhar mais tempo” e outro de “ir para a tela inicial”.
- Cubra a tela inteira. Por padrão, a sobreposição fica recuada pelas barras do sistema e o app bloqueado aparece pelas bordas.
FLAG_LAYOUT_NO_LIMITSresolve isso.FLAG_LAYOUT_IN_SCREENfoi descontinuada na API 30, então só adicione abaixo do R. - “Ir para a tela inicial” tem que ir para a tela inicial de verdade. Esconder a sobreposição só mostra o app bloqueado de novo, e a próxima consulta coloca o bloqueio de volta. Em vez disso, abra o launcher:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - Abra primeiro, esconda depois. O botão de ganhar tempo abre o HonorFit, o que é um início de activity a partir do segundo plano. O Android permite porque o app tem uma janela de sobreposição visível. Então inicie a activity enquanto a sobreposição ainda está aparecendo e só depois remova a sobreposição. Faça ao contrário e o início é bloqueado sem nenhum aviso.
- Sem permissão de sobreposição? Continue contando mesmo assim. Se “Sobrepor a outros apps” não estiver concedido, o serviço continua consumindo o saldo honestamente. Ele só não consegue desenhar o bloqueio até a permissão voltar.
Mantendo o serviço vivo
Esta é a parte que mais demora para acertar, e quase nada depende do seu código.
START_STICKY não basta
Os “apps em suspensão” da Samsung, a MIUI, a OnePlus e companhia matam serviços em segundo plano e depois engolem o reinício sticky. Um bloqueador é o pior caso para isso: as pessoas não abrem o app enquanto ainda têm tempo, então ninguém percebe que o monitor sumiu. O HonorFit arma um alarme repetitivo, inexato e sem wakeup a cada 15 minutos, que reativa o serviço. Ele nunca acorda o celular, e alarmes adiados enquanto a tela estava desligada disparam logo depois que ela liga, que é exatamente quando o monitor importa.
O Android 12+ nem sempre deixa você iniciá-lo
Iniciar um serviço em primeiro plano a partir do segundo plano (um alarme, um reinício sticky) lança ForegroundServiceStartNotAllowedException, a não ser que você tenha uma isenção. Duas que importam aqui: o usuário desativou a otimização de bateria para o app, ou o app tem SYSTEM_ALERT_WINDOW (essa só ajuda antes do Android 15). Então:
- Capture a exceção e saia em silêncio. Derrubar o processo não ajuda ninguém, e a próxima abertura do app vai iniciar o serviço de qualquer jeito.
- Peça ao usuário para deixar o uso da bateria em “Sem restrições”. Os Ajustes do HonorFit têm um card “Funcionamento em segundo plano” que leva a pessoa até a tela certa. Em alguns celulares, essa configuração é a diferença entre funcionar e não funcionar.
Reinicializações e atualizações
Um receiver para BOOT_COMPLETED e MY_PACKAGE_REPLACED reinicia o monitoramento. O segundo é fácil de esquecer: uma atualização automática do Play mata o seu processo e nunca reinicia os serviços dele, então, sem isso, cada atualização desliga o seu bloqueador em silêncio até o usuário abrir o app. Os dois broadcasts são isentos da restrição de início em segundo plano, e um receiver com exported="false" continua recebendo os dois.
Coisas menores
- Alguns apps nunca podem ser bloqueáveis: Configurações, o relógio (os alarmes!) e Segurança pessoal, além de variantes das fabricantes, como o pacote do relógio da Samsung. Filtre esses apps da lista de bloqueio no código nativo, não só no seletor.
- O serviço roda sem JS, então não pode chamar
t(). O app grava os textos da sobreposição e da notificação noSharedPreferencesno idioma atual, e o serviço usa inglês até eles chegarem. - Uma sessão que ficou aberta durante um reinício do serviço não tinha como ser descontada enquanto o serviço estava morto. Ao iniciar, contabilize o que passou (com um limite) e comece uma sessão nova, para ninguém ser cobrado por um tempo que o serviço não estava lá para ver.
- O Android 14+ deixa os usuários dispensarem notificações de serviços em primeiro plano. O serviço continua atualizando a notificação (com uma deduplicação para não republicar a cada segundo), então ela volta.
As concessões
Essa abordagem é mais branda que a acessibilidade, e quero ser franco sobre isso:
- Um app bloqueado fica visível por até um intervalo de consulta (mais ou menos um segundo) antes de a sobreposição aparecer.
- O app bloqueado continua rodando por baixo da sobreposição. Se um vídeo estava tocando, você ainda ouve o som até tocar em “ir para a tela inicial”.
- Depende de a fabricante deixar o seu serviço em paz, e é por isso que a configuração de bateria importa tanto.
Para um bloqueador no sistema de honra, essa é a troca certa. O HonorFit não tenta ser inquebrável. Quem estiver decidido a trapacear pode simplesmente registrar reps que não fez. A ideia é uma pausa e um preço, não uma prisão. Em troca, você ganha uma tela de permissão que diz “acesso ao uso” em vez de “controle total”, uma revisão do Play muito mais simples e um serviço que lê exatamente uma coisa: o nome do pacote do app que está na frente.
Se você também está desenvolvendo para iOS, lá é um mundo completamente diferente. Escrevi sobre o que a documentação do Tempo de Uso da Apple não conta.
HonorFit Blocker é um bloqueador de apps grátis para iPhone e Android. Ganhe tempo de tela com flexões, agachamentos ou qualquer exercício que você escolher. Sistema de honra, sem câmera, sem conta, nada sai do seu celular.