Blokada aplikacji na Androida bez AccessibilityService
Tworzę HonorFit, blokadę aplikacji, w której zarabiasz czas przed ekranem, robiąc pompki (na słowo honoru, bez kamery). Pierwsza wersja na Androida blokowała aplikacje tak, jak robi to większość blokad na Androidzie: przez AccessibilityService. W sierpniu to wyrwałem i zastąpiłem przez UsageStatsManager, usługę pierwszoplanową (foreground service) i okno nakładki. Blokowanie zrobiło się trochę łagodniejsze, onboarding o wiele mniej straszny, a weryfikacja w Google Play dużo prostsza.
Oto jak wygląda ta architektura i na czym się po drodze przejechałem. Cały kod to Kotlin w lokalnym module Expo, ale nic tu nie jest specyficzne dla React Native.
Po co rezygnować z Accessibility API
Usługa ułatwień dostępu to najmocniejsze narzędzie, jakie dostajesz na Androidzie. Dowiadujesz się o każdej zmianie okna w chwili, gdy następuje, a performGlobalAction(GLOBAL_ACTION_HOME) od razu wyrzuca użytkownika z zablokowanej aplikacji. Dlatego korzysta z niej tyle blokad. Koszty:
- Prośba o uprawnienie jest przerażająca. Android mówi użytkownikowi, że aplikacja widzi wszystko na ekranie i może działać w jego imieniu. Dla aplikacji, której hasłem jest „nic nie opuszcza twojego telefonu”, proszenie o to pierwszego dnia źle wygląda, a mnóstwo osób rezygnuje właśnie w tym miejscu.
- Google Play traktuje to jako wrażliwe API. Jeśli nie jesteś narzędziem ułatwień dostępu, potrzebujesz wyraźnej informacji w aplikacji, deklaracji w Play Console i recenzenta, który uzna, że twój przypadek użycia jest OK. Okno z tą informacją miałem już zbudowane. Wolałbym go nie potrzebować.
- Widzi o wiele więcej, niż potrzebuje blokada. Blokadzie potrzebny jest dokładnie jeden fakt: która aplikacja jest na pierwszym planie.
Ten jeden fakt można zdobyć gdzie indziej: ze zdarzeń użycia, na których opiera się Cyfrowa równowaga (Digital Wellbeing).
Ogólny kształt
Trzy uprawnienia, wszystkie przyznawane przez użytkownika w Ustawieniach:
| Uprawnienie | Do czego służy |
|---|---|
PACKAGE_USAGE_STATS („Dostęp do danych o użyciu”) | Odczyt UsageEvents, żeby wiedzieć, która aplikacja jest na wierzchu. Nigdy nie jest przyznawane w czasie działania; odsyłasz użytkownika na ekran Ustawień i sprawdzasz app-op. |
SYSTEM_ALERT_WINDOW („Wyświetlanie nad innymi aplikacjami”) | Rysowanie ekranu blokady nad zablokowaną aplikacją. |
FOREGROUND_SERVICE_SPECIAL_USE | Usługa monitorująca. Blokowanie aplikacji nie pasuje do żadnego z wymienionych typów usług pierwszoplanowych, więc to specialUse z ciągiem podtypu, który wyjaśnia dlaczego. |
Elementy:
ForegroundMonitorServicedziała tylko wtedy, gdy jest czego pilnować (niepusta lista blokowanych aplikacji i przyznany dostęp do danych o użyciu). Raz na sekundę, i tylko przy włączonym ekranie, pytaUsageStatsManager, co przeszło na pierwszy plan. Zablokowana aplikacja, której został czas, zużywa saldo z banku; przy zerze usługa pokazuje nakładkę.- Jej obowiązkowe powiadomienie wyświetla limit („zostało 12 min”), więc to, co Android ci narzuca, wykonuje prawdziwą pracę.
- Cały stan żyje w
SharedPreferences, a nie w JS. Usługa musi działać, gdy środowisko React Native nie żyje, czyli przez większość czasu. Strona JS dopisuje czas i odczytuje saldo przez moduł; egzekwowanie należy do usługi.
Wpis usługi w manifeście:
<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>
Musisz też uzasadnić specialUse w deklaracji usług pierwszoplanowych w Play Console. Opowiedzenie tej samej historii w obu miejscach pomaga.
Odpytywanie UsageStats (i jego opóźniony zapis)
Główna pętla jest niewielka. Przejrzyj zdarzenia od ostatniego odpytania i zapamiętaj pakiet z ostatniego MOVE_TO_FOREGROUND (to ta sama stała co ACTIVITY_RESUMED, przemianowana w 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
}
}
// Re-scan the last 15 s next time: see below.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)
Miły efekt uboczny w porównaniu ze zdarzeniami ułatwień dostępu: klawiatura i panel powiadomień nie są aktywnościami, więc nigdy nie pojawiają się jako fałszywe wyjścia w stylu „użytkownik opuścił aplikację”.
Pułapka: zdarzenia przychodzą z opóźnieniem
Moja pierwsza wersja przesuwała okno wyłącznie do przodu: każde odpytanie obejmowało [lastEnd, now]. Zwykle działało, aż na telefonie uruchomionym na zimno pierwsze otwarcie zablokowanej aplikacji w ogóle nie zostało zauważone. UsageStats zapisuje zdarzenia w swoim magazynie z opóźnieniem, więc zdarzenie ze znacznikiem 10:00:00.900 może się pojawić po odpytaniu, które już objęło 10:00:01. Okno przesuwane wyłącznie do przodu pomija je na zawsze.
Rozwiązanie: każde odpytanie skanuje ponownie zakładkę z przeszłości (ja używam 15 sekund). Zobaczenie tego samego zdarzenia dwa razy niczym nie grozi, bo i tak wygrywa ostatnie wznowienie w oknie. Zdarzenie, które dotrze z ponad 15-sekundowym opóźnieniem, nadal przepadnie, ale takiego nie widziałem.
Pułapka: pierwsze odpytanie musi sięgać daleko wstecz
Kiedy usługa startuje (po restarcie albo gdy włącza się ekran), nie ma „ostatniej znanej aplikacji”. Jeśli użytkownik siedzi na TikToku od 20 minut, a pierwsze odpytanie patrzy tylko minutę wstecz, nie zobaczy niczego, dopóki ten nie przełączy aplikacji. Dlatego pierwsze odpytanie skanuje ostatnią godzinę. To bezpieczne, bo launcher też jest aktywnością: to, co zostało wznowione jako ostatnie, naprawdę jest na wierzchu.
Pułapka: odebrany dostęp wygląda jak cisza
Jeśli użytkownik wyłączy dostęp do danych o użyciu, gdy usługa działa, queryEvents nie rzuca wyjątku. Po prostu nic nie zwraca. Ostatni odczyt („Instagram jest na wierzchu”) traci aktualność, a saldo spływa w nieskończoność. Dlatego każde odpytanie najpierw sprawdza app-op i zatrzymuje zegar, jeśli uprawnienia już nie ma:
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
}
Wyłączony ekran oznacza stop
Gdy ekran się wyłącza, usługa rozlicza otwartą sesję w banku i całkiem przestaje odpytywać. Nikt nie używa aplikacji przy wyłączonym ekranie, a pętla 1 Hz, która działa tylko wtedy, gdy patrzysz na telefon, jest tania. Pierwsze odpytanie po ponownym włączeniu ekranu obsługuje też zmianę dnia (reset o północy, dzienna baza), więc dzieje się to, zanim użytkownik dotrze do zablokowanej aplikacji, nawet jeśli nigdy nie otworzy HonorFit.
Nakładka blokady
Bez ułatwień dostępu nie ma GLOBAL_ACTION_HOME, więc nie możesz wyrzucić użytkownika z aplikacji. Zamiast tego ją zasłaniasz: oknem TYPE_APPLICATION_OVERLAY z przyciskiem „zdobądź więcej czasu” i przyciskiem „wróć do ekranu głównego”.
- Zasłoń cały wyświetlacz. Domyślnie nakładka jest odsunięta o paski systemowe, a zablokowana aplikacja wystaje przy krawędziach. Naprawia to
FLAG_LAYOUT_NO_LIMITS.FLAG_LAYOUT_IN_SCREENjest przestarzała od API 30, więc dodawaj ją tylko poniżej R. - „Wróć do ekranu głównego” musi naprawdę tam wracać. Ukrycie nakładki tylko odsłania z powrotem zablokowaną aplikację, a następne odpytanie znowu zakłada blokadę. Zamiast tego uruchom launcher:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - Najpierw uruchom, potem ukryj. Przycisk zarabiania czasu otwiera HonorFit, czyli uruchamia aktywność z tła. Android na to pozwala, bo aplikacja ma widoczne okno nakładki. Uruchom więc aktywność, póki nakładka jest jeszcze widoczna, a dopiero potem ją usuń. Zrób to w odwrotnej kolejności, a uruchomienie zostanie po cichu zablokowane.
- Brak uprawnienia do nakładki? Licz dalej. Jeśli „Wyświetlanie nad innymi aplikacjami” nie jest przyznane, usługa nadal uczciwie zużywa saldo. Po prostu nie może narysować blokady, dopóki uprawnienie nie wróci.
Jak utrzymać usługę przy życiu
Dopracowanie tej części zajmuje najwięcej czasu i w większości nie chodzi w niej o twój kod.
START_STICKY nie wystarcza
„Uśpione aplikacje” Samsunga, MIUI, OnePlus i spółka zabijają usługi w tle, a potem połykają restart sticky. Blokada to tu najgorszy przypadek: ludzie jej nie otwierają, dopóki mają czas, więc nikt nie zauważa, że monitora już nie ma. HonorFit ustawia niedokładny, niewybudzający, powtarzalny alarm co 15 minut, który przywraca usługę. Nigdy nie wybudza telefonu, a alarmy odroczone przy wyłączonym ekranie odpalają się wkrótce po jego włączeniu, czyli dokładnie wtedy, gdy monitor ma znaczenie.
Android 12+ nie zawsze pozwoli ci ją uruchomić
Uruchomienie usługi pierwszoplanowej z tła (z alarmu, przy restarcie sticky) rzuca ForegroundServiceStartNotAllowedException, chyba że masz wyjątek. Dwa, które się tu liczą: użytkownik wyłączył optymalizację baterii dla aplikacji albo aplikacja ma SYSTEM_ALERT_WINDOW (to pomaga tylko przed Androidem 15). Więc:
- Złap wyjątek i po cichu się wycofaj. Wywalenie procesu nikomu nie pomoże, a następne otwarcie aplikacji i tak uruchomi usługę.
- Poproś użytkownika o ustawienie użycia baterii na „Bez ograniczeń”. W Ustawieniach HonorFit jest karta „Niezawodność w tle”, która prowadzi do właściwego ekranu. Na niektórych telefonach od tego ustawienia zależy, czy to w ogóle działa.
Restarty i aktualizacje
Odbiornik (receiver) dla BOOT_COMPLETED i MY_PACKAGE_REPLACED wznawia monitorowanie. O drugim łatwo zapomnieć: automatyczna aktualizacja z Play zabija twój proces i nigdy nie uruchamia ponownie jego usług, więc bez tego każda aktualizacja po cichu wyłącza blokadę, dopóki użytkownik nie otworzy aplikacji. Oba broadcasty są zwolnione z ograniczenia uruchamiania z tła, a odbiornik z exported="false" nadal je dostaje.
Drobniejsze sprawy
- Niektórych aplikacji nigdy nie wolno blokować: Ustawień, zegara (alarmy!) i Bezpieczeństwa osobistego, a także wariantów producentów, np. pakietu zegara Samsunga. Odfiltruj je z listy blokowanych w kodzie natywnym, a nie tylko w oknie wyboru.
- Usługa działa bez JS, więc nie może wywołać
t(). Aplikacja zapisuje teksty nakładki i powiadomienia wSharedPreferencesw bieżącym języku, a dopóki ich nie ma, usługa używa angielskiego. - Sesja otwarta w trakcie restartu usługi nie mogła zużywać salda, gdy usługa nie żyła. Przy starcie rozlicz to, co upłynęło (z górnym limitem), i zacznij nową sesję, żeby nikt nie płacił za czas, którego usługa nie mogła zobaczyć.
- Android 14+ pozwala użytkownikom odsuwać powiadomienia usług pierwszoplanowych. Usługa ciągle je aktualizuje (z deduplikacją, żeby nie publikować go od nowa co sekundę), więc wraca.
Kompromisy
To podejście jest łagodniejsze niż ułatwienia dostępu i chcę to powiedzieć wprost:
- Zablokowana aplikacja jest widoczna przez maksymalnie jeden interwał odpytywania (około sekundy), zanim pojawi się nakładka.
- Zablokowana aplikacja dalej działa pod nakładką. Jeśli leciał film, nadal go słychać, dopóki nie stukniesz „wróć do ekranu głównego”.
- Wszystko zależy od tego, czy producent zostawi twoją usługę w spokoju, i dlatego ustawienie baterii jest tak ważne.
Dla blokady na słowo honoru to właściwy kompromis. HonorFit nie próbuje być nie do obejścia. Ktoś zdeterminowany, żeby oszukiwać, może po prostu zapisać powtórzenia, których nie zrobił. Chodzi o pauzę i cenę, a nie o więzienie. W zamian dostajesz ekran uprawnień, na którym jest „dostęp do danych o użyciu” zamiast „pełnej kontroli”, dużo prostszą weryfikację w Play i usługę, która odczytuje dokładnie jedną rzecz: nazwę pakietu aplikacji na wierzchu.
Jeśli budujesz też na iOS, tam jest zupełnie inny świat. Opisałem, czego nie mówi dokumentacja Screen Time od Apple.
HonorFit Blocker to darmowa blokada aplikacji na iPhone'a i Androida. Zarabiaj czas przed ekranem pompkami, przysiadami albo dowolnym wybranym ćwiczeniem. Na słowo honoru, bez kamery, bez konta, nic nie opuszcza twojego telefonu.