Creare un’app per bloccare le app su Android senza AccessibilityService
Sviluppo HonorFit, un’app per bloccare le app in cui il tempo schermo te lo guadagni facendo flessioni (sulla parola, niente fotocamera). La prima versione per Android bloccava le app come fa la maggior parte dei blocker su Android: con un AccessibilityService. Ad agosto l’ho tolto di mezzo e l’ho sostituito con UsageStatsManager, un servizio in primo piano e una finestra in overlay. Il blocco è diventato un po’ più morbido, l’onboarding molto meno spaventoso e la revisione su Play molto più semplice.
Ecco com’è fatta questa architettura e le cose che mi hanno fregato lungo la strada. Tutto il codice è Kotlin dentro un modulo Expo locale, ma niente di quello che c’è qui è specifico di React Native.
Perché abbandonare l’API di accessibilità
Un servizio di accessibilità è lo strumento più potente che hai su Android. Vieni a sapere di ogni cambio di finestra nell’istante in cui succede, e performGlobalAction(GLOBAL_ACTION_HOME) butta fuori l’utente da un’app bloccata all’istante. Per questo tanti blocker lo usano. I costi:
- La richiesta di autorizzazione fa paura. Android dice all’utente che l’app può vedere tutto quello che c’è sullo schermo e agire al posto suo. Per un’app che si presenta con “nulla esce dal tuo telefono”, chiederlo il primo giorno fa una pessima impressione, e tanta gente molla proprio lì.
- Google Play la tratta come un’API sensibile. Se non sei uno strumento di accessibilità, ti servono un’informativa ben visibile nell’app, una dichiarazione in Play Console e un revisore che accetti il tuo caso d’uso. Avevo già costruito la finestra con l’informativa. Avrei preferito non averne bisogno.
- Vede molto più di quanto serva a un blocker. A un blocker serve esattamente un’informazione: quale app è in primo piano.
Quell’informazione si trova anche altrove: negli eventi di utilizzo su cui si basa Benessere digitale.
Com’è fatta
Tre autorizzazioni, tutte concesse dall’utente nelle Impostazioni:
| Autorizzazione | A cosa serve |
|---|---|
PACKAGE_USAGE_STATS (“Accesso ai dati di utilizzo”) | Leggere gli UsageEvents per sapere quale app è davanti. Non si concede mai a runtime: mandi l’utente nella schermata delle Impostazioni e controlli l’app-op. |
SYSTEM_ALERT_WINDOW (“Mostra sopra altre app”) | Disegnare la schermata di blocco sopra un’app bloccata. |
FOREGROUND_SERVICE_SPECIAL_USE | Il servizio di monitoraggio. Il blocco delle app non rientra in nessuno dei tipi di servizio in primo piano previsti, quindi è specialUse, con una stringa di sottotipo che spiega il perché. |
I pezzi:
ForegroundMonitorServicegira solo quando c’è qualcosa da far rispettare (una lista di blocco non vuota e l’accesso ai dati di utilizzo concesso). Una volta al secondo, e solo con lo schermo acceso, chiede aUsageStatsManagercosa è passato in primo piano. Un’app bloccata con del tempo rimasto consuma il saldo in banca; a zero mostra l’overlay.- La sua notifica obbligatoria è il display della quota (“12 min rimasti”), così la cosa che Android ti impone fa un lavoro vero.
- Tutto lo stato vive nelle
SharedPreferences, non nel JS. Il servizio deve continuare a funzionare quando il runtime di React Native è morto, cioè quasi sempre. Il lato JS deposita tempo e legge il saldo tramite il modulo; è il servizio a far rispettare il blocco.
La voce del servizio nel manifest:
<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>
Devi anche giustificare specialUse nella dichiarazione dei servizi in primo piano di Play Console. Raccontare la stessa storia in entrambi i posti aiuta.
Interrogare UsageStats (e il suo ritardo di scrittura)
Il ciclo principale è piccolo. Scorri gli eventi dall’ultima interrogazione e tieni l’ultimo pacchetto in MOVE_TO_FOREGROUND (è la stessa costante di ACTIVITY_RESUMED, rinominata nell’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
}
}
// La prossima volta riscansiona gli ultimi 15 s: vedi sotto.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)
Un bell’effetto collaterale rispetto agli eventi di accessibilità: la tastiera e la tendina delle notifiche non sono activity, quindi non compaiono mai come finte uscite del tipo “l’utente ha lasciato l’app”.
Insidia: gli eventi arrivano in ritardo
La mia prima versione faceva avanzare la finestra in modo rigoroso: ogni interrogazione chiedeva [lastEnd, now]. Funzionava quasi sempre, poi su un telefono appena avviato la primissima apertura di un’app bloccata non veniva mai vista. UsageStats scrive gli eventi nel suo archivio con un ritardo, quindi un evento con timestamp 10:00:00.900 può comparire dopo un’interrogazione che copriva già le 10:00:01. Una finestra che avanza rigorosamente lo salta per sempre.
La soluzione: ogni interrogazione riscansiona una sovrapposizione all’indietro (io uso 15 secondi). Vedere lo stesso evento due volte non fa danni, perché in ogni caso vince l’ultima ripresa nella finestra. Un evento che arriva con più di 15 secondi di ritardo si perde comunque, ma non ne ho mai visto uno.
Insidia: la prima interrogazione deve guardare indietro a lungo
Quando il servizio parte (dopo un riavvio, o quando si accende lo schermo) non c’è nessuna “ultima app conosciuta”. Se l’utente è su TikTok da 20 minuti e la prima interrogazione guarda indietro solo di un minuto, non vede niente finché non cambia app. Quindi la prima interrogazione scansiona l’ultima ora. È sicuro perché anche il launcher è un’activity: quello che è ripreso per ultimo è davvero quello che c’è davanti.
Insidia: un accesso revocato sembra silenzio
Se l’utente disattiva l’accesso ai dati di utilizzo mentre il servizio gira, queryEvents non lancia eccezioni. Semplicemente non restituisce niente. La tua ultima lettura (“Instagram è davanti”) diventa vecchia e il saldo si consuma all’infinito. Quindi ogni interrogazione controlla prima l’app-op e ferma il cronometro se l’accesso non c’è più:
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
}
Schermo spento vuol dire stop
Quando lo schermo si spegne, il servizio mette in banca la sessione aperta e smette del tutto di interrogare. Nessuno usa un’app a schermo spento, e un ciclo a 1 Hz che gira solo mentre guardi il telefono costa poco. La prima interrogazione dopo la riaccensione dello schermo gestisce anche il cambio di giorno (azzeramento a mezzanotte, base giornaliera), così avviene prima che l’utente arrivi a un’app bloccata, anche se non apre mai HonorFit.
L’overlay di blocco
Senza accessibilità non c’è GLOBAL_ACTION_HOME, quindi non puoi buttare l’utente fuori da un’app. La copri invece: una finestra TYPE_APPLICATION_OVERLAY con un pulsante “guadagna altro tempo” e un pulsante “torna alla home”.
- Copri tutto il display. Di default l’overlay è rientrato rispetto alle barre di sistema e l’app bloccata spunta dai bordi.
FLAG_LAYOUT_NO_LIMITSlo risolve.FLAG_LAYOUT_IN_SCREENè deprecato dall’API 30, quindi aggiungilo solo sotto R. - “Torna alla home” deve tornare davvero alla home. Nascondere l’overlay rivela di nuovo l’app bloccata, e l’interrogazione successiva rimette il blocco. Avvia invece il launcher:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - Prima avvia, poi nascondi. Il pulsante per guadagnare tempo apre HonorFit, che è l’avvio di un’activity dal background. Android lo permette perché l’app ha una finestra di overlay visibile. Quindi avvia l’activity mentre l’overlay è ancora visibile, poi toglilo. Al contrario, l’avvio viene bloccato in silenzio.
- Niente autorizzazione per l’overlay? Continua comunque a contare. Se “Mostra sopra altre app” non è concessa, il servizio continua a consumare il saldo onestamente. Solo che non può disegnare il blocco finché l’autorizzazione non torna.
Tenere vivo il servizio
Questa è la parte che richiede più tempo per venire bene, e quasi non dipende dal tuo codice.
START_STICKY non basta
Le “app in sospensione” di Samsung, MIUI, OnePlus e compagnia uccidono i servizi in background e poi si mangiano il riavvio sticky. Un blocker è il caso peggiore: la gente non lo apre finché ha tempo a disposizione, quindi nessuno si accorge che il monitoraggio è sparito. HonorFit programma ogni 15 minuti un allarme ripetuto, inesatto e senza wakeup, che rilancia il servizio. Non sveglia mai il telefono, e gli allarmi rimandati mentre lo schermo era spento scattano poco dopo che si riaccende, che è proprio il momento in cui il monitoraggio conta.
Android 12+ non sempre ti lascia avviarlo
Avviare un servizio in primo piano dal background (un allarme, un riavvio sticky) lancia ForegroundServiceStartNotAllowedException, a meno che tu non abbia un’esenzione. Due contano qui: l’utente ha disattivato l’ottimizzazione della batteria per l’app, oppure l’app ha SYSTEM_ALERT_WINDOW (quest’ultima aiuta solo prima di Android 15). Quindi:
- Cattura l’eccezione e lascia perdere in silenzio. Far crashare il processo non aiuta nessuno, e alla prossima apertura dell’app il servizio partirà comunque.
- Chiedi all’utente di impostare l’utilizzo della batteria su “Senza restrizioni”. Le Impostazioni di HonorFit hanno una scheda “Affidabilità in background” che lo porta alla schermata giusta. Su alcuni telefoni quell’impostazione fa la differenza tra funzionare e non funzionare.
Riavvii e aggiornamenti
Un receiver per BOOT_COMPLETED e MY_PACKAGE_REPLACED fa ripartire il monitoraggio. Il secondo è facile da dimenticare: un aggiornamento automatico da Play uccide il tuo processo e non riavvia mai i suoi servizi, quindi senza di lui ogni aggiornamento spegne di nascosto il tuo blocker finché l’utente non apre l’app. Entrambi i broadcast sono esenti dalla restrizione sull’avvio in background, e un receiver exported="false" li riceve comunque.
Dettagli più piccoli
- Alcune app non devono mai poter essere bloccate: le Impostazioni, l’orologio (le sveglie!) e Sicurezza personale, più le varianti dei produttori come il pacchetto dell’orologio di Samsung. Filtrale dalla lista di blocco nel codice nativo, non solo nel selettore.
- Il servizio gira senza JS, quindi non può chiamare
t(). L’app spinge i testi dell’overlay e della notifica nelleSharedPreferencesnella lingua attuale, e il servizio ripiega sull’inglese finché non arrivano. - Una sessione rimasta aperta durante un riavvio del servizio non poteva consumare il saldo mentre il servizio era morto. All’avvio, metti in banca il tempo trascorso (con un limite) e inizia una sessione nuova, così nessuno paga per del tempo che il servizio non era lì a vedere.
- Da Android 14 gli utenti possono scorrere via le notifiche dei servizi in primo piano. Il servizio continua ad aggiornarla (con una deduplicazione, così non la ripubblica ogni secondo), quindi torna.
I compromessi
Questo approccio è più morbido dell’accessibilità, e voglio dirlo chiaramente:
- Un’app bloccata resta visibile fino a un intervallo di interrogazione (circa un secondo) prima che arrivi l’overlay.
- L’app bloccata continua a girare sotto l’overlay. Se stava andando un video, puoi ancora sentirlo finché non tocchi “torna alla home”.
- Dipende dal fatto che il produttore lasci in pace il tuo servizio, ed è per questo che l’impostazione della batteria conta così tanto.
Per un blocker che funziona sulla parola, è il compromesso giusto. HonorFit non cerca di essere inattaccabile. Chi è deciso a imbrogliare può semplicemente segnare ripetizioni che non ha fatto. Lo scopo è una pausa e un prezzo, non una prigione. In cambio hai una schermata di autorizzazione che dice “accesso ai dati di utilizzo” invece di “controllo completo”, una revisione su Play molto più semplice e un servizio che legge esattamente una cosa: il nome del pacchetto dell’app in primo piano.
Se sviluppi anche per iOS, lì è tutto un altro mondo. Ho scritto quello che la documentazione di Screen Time di Apple non ti dice.
HonorFit Blocker è un’app gratuita per iPhone e Android che blocca le app. Guadagna tempo schermo con flessioni, squat o qualsiasi esercizio tu scelga. Funziona sulla parola: niente fotocamera, niente account, nulla esce dal tuo telefono.