AccessibilityService olmadan Android uygulama engelleyici yapmak
Ekran süresini şınav çekerek kazandığın bir uygulama engelleyici olan HonorFit'i yapıyorum (onur sistemi, kamera yok). İlk Android sürümü uygulamaları çoğu Android engelleyicisinin yaptığı gibi engelliyordu: bir AccessibilityService ile. Ağustosta bunu söküp attım ve yerine UsageStatsManager, bir ön plan hizmeti (foreground service) ve bir overlay penceresi koydum. Engelleme biraz yumuşadı, ilk kurulum çok daha az korkutucu oldu ve Play incelemesi çok daha basitleşti.
Bu yazı o mimarinin neye benzediğini ve yol boyunca başımı ağrıtan şeyleri anlatıyor. Kodun tamamı yerel bir Expo modülünün içinde Kotlin, ama buradaki hiçbir şey React Native'e özgü değil.
Accessibility API'den neden vazgeçmeli
Erişilebilirlik hizmeti, Android'de eline geçebilecek en güçlü araç. Her pencere değişikliğinden anında haberin olur ve performGlobalAction(GLOBAL_ACTION_HOME) kullanıcıyı engelli bir uygulamadan o anda dışarı atar. Bu kadar çok engelleyicinin onu kullanmasının sebebi bu. Bedeli ise:
- İzin istemi ürkütücü. Android kullanıcıya, uygulamanın ekrandaki her şeyi görebildiğini ve onun adına işlem yapabildiğini söylüyor. Sloganı “hiçbir şey telefonundan çıkmıyor” olan bir uygulama için ilk gün bunu istemek kötü görünüyor ve birçok kişi tam orada vazgeçiyor.
- Google Play bunu hassas bir API olarak görüyor. Bir erişilebilirlik aracı değilsen, uygulama içinde belirgin bir açıklamaya, Play Console'da bir beyana ve kullanım amacının uygun olduğunu kabul eden bir inceleyiciye ihtiyacın var. Açıklama penceresini çoktan yapmıştım. Hiç ihtiyaç duymamayı tercih ederim.
- Bir engelleyicinin ihtiyacından çok daha fazlasını görüyor. Bir engelleyicinin tam olarak tek bir bilgiye ihtiyacı var: ön planda hangi uygulamanın olduğu.
O tek bilgi başka bir yerden de alınabiliyor: Dijital Denge'nin (Digital Wellbeing) dayandığı kullanım olaylarından.
Genel yapı
Üç izin; hepsini kullanıcı Ayarlar'dan veriyor:
| İzin | Ne için |
|---|---|
PACKAGE_USAGE_STATS (“Kullanım erişimi”) | Önde hangi uygulamanın olduğunu öğrenmek için UsageEvents okumak. Asla çalışma zamanında verilmez; kullanıcıyı Ayarlar ekranına gönderir ve app-op'u kontrol edersin. |
SYSTEM_ALERT_WINDOW (“Diğer uygulamaların üzerinde göster”) | Kilit ekranını engelli bir uygulamanın üstüne çizmek. |
FOREGROUND_SERVICE_SPECIAL_USE | İzleme hizmeti. Uygulama engelleme, listelenen ön plan hizmeti türlerinin hiçbirine uymuyor, bu yüzden nedenini açıklayan bir alt tür metniyle specialUse kullanılıyor. |
Parçalar:
ForegroundMonitorServiceyalnızca denetlenecek bir şey varken çalışıyor (boş olmayan bir engel listesi ve verilmiş Kullanım erişimi). Saniyede bir kez ve yalnızca ekran açıkkenUsageStatsManager'a ön plana neyin geldiğini soruyor. Süresi kalan engelli bir uygulama biriken bakiyeyi eritiyor; sıfırda hizmet overlay'i gösteriyor.- Zorunlu bildirimi aynı zamanda kota göstergesi (“12 dk kaldı”), yani Android'in sana dayattığı şey gerçek bir iş görüyor.
- Tüm durum JS'te değil,
SharedPreferences'ta tutuluyor. Hizmetin, React Native çalışma ortamı ölüyken de çalışmaya devam etmesi gerekiyor, ki zamanın çoğu böyle. JS tarafı modül üzerinden süre yatırıyor ve bakiyeyi okuyor; denetim hizmetin işi.
Hizmetin manifest girdisi:
<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>
specialUse'u ayrıca Play Console'daki ön plan hizmeti beyanında da gerekçelendiriyorsun. İki yerde de aynı hikâyeyi anlatmak işe yarıyor.
UsageStats'i yoklamak (ve yazma gecikmesi)
Ana döngü küçük. Son yoklamadan beri olan olayları tara ve son MOVE_TO_FOREGROUND olayının paketini tut (ACTIVITY_RESUMED ile aynı sabit, API 29'da yeniden adlandırıldı):
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)
Erişilebilirlik olaylarına kıyasla hoş bir yan etki: klavye ve bildirim paneli activity değil, bu yüzden asla sahte “kullanıcı uygulamadan çıktı” çıkışları olarak görünmüyorlar.
Tuzak: olaylar geç geliyor
İlk sürümüm pencereyi yalnızca ileriye doğru kaydırıyordu: her yoklama [lastEnd, now] aralığını sorguluyordu. Çoğunlukla işe yaradı, ta ki soğuk başlatılmış bir telefonda engelli bir uygulamanın ilk açılışı hiç görülmeyene kadar. UsageStats olayları deposuna gecikmeyle yazıyor, yani 10:00:00.900 zaman damgalı bir olay, 10:00:01'i zaten kapsamış bir yoklamadan sonra ortaya çıkabiliyor. Yalnızca ileri giden bir pencere onu sonsuza dek atlıyor.
Çözüm: her yoklama geriye doğru bir örtüşme payını yeniden tarıyor (ben 15 saniye kullanıyorum). Aynı olayı iki kez görmek zararsız, çünkü her durumda penceredeki son resume kazanıyor. 15 saniyeden daha geç düşen bir olay yine de kaybolur, ama hiç görmedim.
Tuzak: ilk yoklamanın uzun bir geriye bakışa ihtiyacı var
Hizmet başladığında (bir yeniden başlatmadan sonra ya da ekran açıldığında) “bilinen son uygulama” yok. Kullanıcı 20 dakikadır TikTok'taysa ve ilk yoklama sadece bir dakika geriye bakıyorsa, kullanıcı uygulama değiştirene kadar hiçbir şey görmez. Bu yüzden ilk yoklama son bir saati tarıyor. Bu güvenli, çünkü başlatıcı (launcher) da bir activity: en son resume olan şey gerçekten de önde olan şey.
Tuzak: geri alınan erişim sessizlik gibi görünüyor
Kullanıcı hizmet çalışırken Kullanım erişimini kapatırsa queryEvents hata fırlatmıyor. Sadece hiçbir şey döndürmüyor. Son okuman (“Instagram önde”) bayatlıyor ve bakiye sonsuza dek eriyor. Bu yüzden her yoklama önce app-op'u kontrol ediyor ve izin gittiyse saati durduruyor:
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
}
Ekran kapalıysa dur
Ekran kapandığında hizmet açık oturumu bakiyeye işliyor ve yoklamayı tamamen durduruyor. Ekran kapalıyken kimse uygulama kullanmıyor ve yalnızca telefonuna bakarken çalışan 1 Hz'lik bir döngü ucuz. Ekran tekrar açıldıktan sonraki ilk yoklama gün devrini de (gece yarısı sıfırlama, günlük taban) hallediyor; böylece kullanıcı HonorFit'i hiç açmasa bile bu iş, o engelli bir uygulamaya ulaşmadan önce yapılmış oluyor.
Kilit overlay'i
Erişilebilirlik olmadan GLOBAL_ACTION_HOME yok, yani kullanıcıyı bir uygulamadan dışarı atamazsın. Onun yerine üstünü örtüyorsun: “daha fazla süre kazan” düğmesi ve “ana ekrana dön” düğmesi olan bir TYPE_APPLICATION_OVERLAY penceresiyle.
- Tüm ekranı kapla. Varsayılan olarak overlay sistem çubukları kadar içeriden başlıyor ve engelli uygulama kenarlardan görünüyor.
FLAG_LAYOUT_NO_LIMITSbunu düzeltiyor.FLAG_LAYOUT_IN_SCREENAPI 30'da kullanımdan kaldırıldı, o yüzden onu sadece R'nin altında ekle. - “Ana ekrana dön” gerçekten ana ekrana dönmeli. Overlay'i gizlemek sadece engelli uygulamayı tekrar ortaya çıkarıyor ve bir sonraki yoklama kilidi geri koyuyor. Onun yerine başlatıcıyı aç:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - Önce başlat, sonra gizle. Kazanma düğmesi HonorFit'i açıyor; bu da arka plandan bir activity başlatmak demek. Android buna izin veriyor, çünkü uygulamanın görünür bir overlay penceresi var. Yani activity'yi overlay hâlâ görünürken başlat, sonra overlay'i kaldır. Tersini yaparsan başlatma sessizce engelleniyor.
- Overlay izni yok mu? Yine de saymaya devam et. “Diğer uygulamaların üzerinde göster” izni verilmemişse hizmet bakiyeyi yine dürüstçe eritiyor. Sadece izin geri gelene kadar kilidi çizemiyor.
Hizmeti hayatta tutmak
Doğru yapması en uzun süren kısım bu ve çoğunlukla senin kodunla ilgili değil.
START_STICKY yetmiyor
Samsung'un “uyuyan uygulamaları”, MIUI, OnePlus ve benzerleri arka plan hizmetlerini öldürüyor ve sonra sticky yeniden başlatmayı yutuyor. Bir engelleyici bunun için en kötü senaryo: insanlar süreleri varken onu açmıyor, bu yüzden izleyicinin gittiğini kimse fark etmiyor. HonorFit, hizmeti yeniden ayağa kaldıran, 15 dakikada bir tekrarlanan, kesin zamanlı olmayan ve cihazı uyandırmayan bir alarm kuruyor. Telefonu asla uyandırmıyor ve ekran kapalıyken ertelenen alarmlar ekran açıldıktan kısa süre sonra çalışıyor; bu da tam olarak izleyicinin önemli olduğu an.
Android 12+ onu başlatmana her zaman izin vermiyor
Arka plandan (bir alarmdan, sticky yeniden başlatmadan) bir ön plan hizmeti başlatmak, bir muafiyetin yoksa ForegroundServiceStartNotAllowedException fırlatıyor. Burada önemli olan iki muafiyet var: kullanıcı uygulama için pil optimizasyonunu kapatmışsa ya da uygulamanın SYSTEM_ALERT_WINDOW izni varsa (bu sonuncusu sadece Android 15'ten önce işe yarıyor). Yani:
- İstisnayı yakala ve sessizce çık. Süreci çökertmek kimseye yaramaz; bir sonraki uygulama açılışı zaten hizmeti başlatacak.
- Kullanıcıdan pil kullanımını “Kısıtlanmamış” yapmasını iste. HonorFit'in Ayarlar'ında onu doğru ekrana götüren bir “Arka plan güvenilirliği” kartı var. Bazı telefonlarda çalışıp çalışmaması bu ayara bağlı.
Yeniden başlatmalar ve güncellemeler
BOOT_COMPLETED ve MY_PACKAGE_REPLACED için bir receiver izlemeyi yeniden başlatıyor. İkincisini unutmak kolay: Play'in otomatik güncellemesi sürecini öldürüyor ve hizmetlerini asla yeniden başlatmıyor; yani o olmadan her güncelleme, kullanıcı uygulamayı açana kadar engelleyicini sessizce kapatıyor. İki yayın da arka plandan başlatma kısıtlamasından muaf ve exported="false" olan bir receiver da onları alıyor.
Daha küçük şeyler
- Bazı uygulamalar asla engellenebilir olmamalı: Ayarlar, saat (alarmlar!) ve Kişisel Güvenlik, artı Samsung'un saat paketi gibi üretici varyantları. Bunları sadece seçicide değil, native kodda da engel listesinden ayıkla.
- Hizmet JS olmadan çalışıyor, yani
t()çağıramıyor. Uygulama overlay ve bildirim metinlerini geçerli dildeSharedPreferences'a yazıyor, hizmet de bunlar gelene kadar İngilizce kullanıyor. - Hizmet yeniden başlatılırken açık kalan bir oturum, hizmet ölüyken bakiyeden düşemiyordu. Başlangıçta geçen süreyi (bir üst sınırla) hesaba işle ve yeni bir oturum başlat; böylece kimse hizmetin göremediği bir süre için ödeme yapmaz.
- Android 14+ kullanıcıların ön plan hizmeti bildirimlerini kaydırıp kapatmasına izin veriyor. Hizmet bildirimi güncellemeye devam ediyor (her saniye yeniden yayınlamasın diye tekilleştirmeyle), böylece bildirim geri geliyor.
Ödünleşimler
Bu yaklaşım erişilebilirlikten daha yumuşak ve bu konuda açık olmak istiyorum:
- Engelli bir uygulama, overlay gelmeden önce en fazla bir yoklama aralığı (yaklaşık bir saniye) boyunca görünüyor.
- Engelli uygulama overlay'in altında çalışmaya devam ediyor. Bir video oynuyorsa, “ana ekrana dön”e dokunana kadar sesini duymaya devam edersin.
- Üreticinin hizmetine dokunmamasına bağlı; pil ayarının bu kadar önemli olmasının sebebi de bu.
Onur sistemine dayanan bir engelleyici için bu doğru bir ödünleşim. HonorFit kırılmaz olmaya çalışmıyor. Hile yapmaya kararlı biri zaten yapmadığı tekrarları kaydedebilir. Amaç bir hapishane değil, bir duraklama ve bir bedel. Karşılığında “tam kontrol” yerine “kullanım erişimi” diyen bir izin ekranı, çok daha basit bir Play incelemesi ve tam olarak tek bir şeyi okuyan bir hizmet elde ediyorsun: öndeki uygulamanın paket adı.
iOS için de geliştiriyorsan, orası bambaşka bir dünya. Apple'ın Screen Time belgelerinin söylemediklerini de yazdım.
HonorFit Blocker, iPhone ve Android için ücretsiz bir uygulama engelleyici. Ekran süresini şınavla, squat'la ya da seçtiğin herhangi bir egzersizle kazan. Onur sistemi, kamera yok, hesap yok, hiçbir şey telefonundan çıkmıyor.