Membuat aplikasi blokir Android tanpa AccessibilityService
Aku membuat HonorFit, aplikasi blokir tempat kamu mendapatkan screen time dengan push up (sistem kejujuran, tanpa kamera). Versi Android pertamanya memblokir aplikasi dengan cara yang dipakai kebanyakan aplikasi blokir Android: lewat AccessibilityService. Bulan Agustus aku mencabut semua itu dan menggantinya dengan UsageStatsManager, sebuah foreground service, dan jendela overlay. Blokirnya jadi sedikit lebih longgar, onboarding-nya jadi jauh tidak semenakutkan dulu, dan review Play jadi jauh lebih sederhana.
Ini gambaran arsitekturnya dan hal-hal yang menjebakku di sepanjang jalan. Semua kodenya Kotlin di dalam modul Expo lokal, tapi tidak ada yang khusus React Native di sini.
Kenapa membuang Accessibility API
Accessibility service adalah alat paling kuat yang kamu dapat di Android. Kamu dikabari setiap kali ada perubahan jendela, saat itu juga, dan performGlobalAction(GLOBAL_ACTION_HOME) langsung menendang pengguna keluar dari aplikasi yang diblokir. Makanya banyak sekali aplikasi blokir memakainya. Harganya:
- Prompt izinnya menakutkan. Android memberi tahu pengguna bahwa aplikasinya bisa melihat semua yang ada di layar dan bertindak atas nama mereka. Untuk aplikasi yang jualannya “tidak ada yang keluar dari HP-mu”, meminta itu di hari pertama kelihatan buruk, dan banyak orang langsung mundur di situ.
- Google Play menganggapnya API sensitif. Kalau kamu bukan alat aksesibilitas, kamu butuh pengungkapan yang jelas di dalam aplikasi, deklarasi di Play Console, dan reviewer yang setuju bahwa kasus penggunaanmu boleh. Modal pengungkapannya sudah aku buat. Tapi aku lebih suka tidak membutuhkannya.
- Ia melihat jauh lebih banyak dari yang dibutuhkan aplikasi blokir. Aplikasi blokir cuma butuh satu fakta: aplikasi mana yang sedang di depan.
Fakta itu tersedia di tempat lain: event pemakaian yang ada di balik Digital Wellbeing.
Bentuk keseluruhannya
Tiga izin, semuanya diberikan pengguna di Pengaturan:
| Izin | Untuk apa |
|---|---|
PACKAGE_USAGE_STATS (“Usage access”) | Membaca UsageEvents untuk tahu aplikasi mana yang di depan. Tidak pernah diberikan saat runtime; kamu mengarahkan pengguna ke layar Pengaturan lalu mengecek app-op-nya. |
SYSTEM_ALERT_WINDOW (“Display over other apps”) | Menggambar layar kunci di atas aplikasi yang diblokir. |
FOREGROUND_SERVICE_SPECIAL_USE | Service monitornya. Blokir aplikasi tidak cocok dengan tipe foreground service mana pun yang tersedia, jadi pakai specialUse dengan string subtipe yang menjelaskan alasannya. |
Komponen-komponennya:
ForegroundMonitorServicehanya berjalan selama ada yang perlu ditegakkan (daftar blokir tidak kosong dan Usage access sudah diberikan). Sekali per detik, dan hanya saat layar menyala, service ini bertanya keUsageStatsManageraplikasi apa yang baru masuk ke depan. Aplikasi yang diblokir dan masih punya waktu akan menguras saldo yang tersimpan; saat saldonya nol, overlay ditampilkan.- Notifikasi wajibnya sekaligus jadi tampilan kuota (“sisa 12 mnt”), jadi hal yang dipaksakan Android padamu punya fungsi nyata.
- Semua state disimpan di
SharedPreferences, bukan di JS. Service-nya harus tetap bekerja saat runtime React Native mati, yang sebenarnya hampir sepanjang waktu. Sisi JS menyetor waktu dan membaca saldo lewat modul; service-lah yang memegang penegakannya.
Entri manifest untuk service-nya:
<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>
Kamu juga perlu menjelaskan alasan specialUse di deklarasi foreground service di Play Console. Menceritakan hal yang sama di kedua tempat itu membantu.
Polling UsageStats (dan jeda penulisannya)
Loop utamanya kecil. Pindai event sejak polling terakhir dan simpan package MOVE_TO_FOREGROUND yang terakhir (konstantanya sama dengan ACTIVITY_RESUMED, hanya ganti nama di 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
}
}
// Pindai ulang 15 detik terakhir di polling berikutnya: lihat di bawah.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)
Bonus dibanding event aksesibilitas: keyboard dan panel notifikasi bukan activity, jadi keduanya tidak pernah muncul sebagai “pengguna keluar dari aplikasi” palsu.
Jebakan: event datang terlambat
Versi pertamaku menggeser jendela waktunya selalu maju: tiap polling meng-query [lastEnd, now]. Biasanya jalan, tapi di HP yang baru dinyalakan, pembukaan aplikasi terblokir yang pertama tidak pernah terlihat. UsageStats menulis event ke penyimpanannya dengan jeda, jadi event bertanda waktu 10:00:00.900 bisa muncul setelah polling yang sudah mencakup 10:00:01. Jendela yang selalu maju akan melewatkannya selamanya.
Solusinya: setiap polling memindai ulang tumpang-tindih di belakangnya (aku pakai 15 detik). Melihat event yang sama dua kali tidak masalah, karena resume terakhir di jendela itu yang menang. Event yang datang lebih dari 15 detik terlambat tetap akan hilang, tapi aku belum pernah melihatnya.
Jebakan: polling pertama butuh lookback yang panjang
Saat service mulai (setelah restart, atau saat layar menyala) belum ada “aplikasi terakhir yang diketahui”. Kalau pengguna sudah 20 menit di TikTok dan polling pertama cuma melihat satu menit ke belakang, service tidak melihat apa-apa sampai pengguna pindah aplikasi. Jadi polling pertama memindai satu jam terakhir. Itu aman karena launcher juga sebuah activity: apa pun yang terakhir di-resume memang benar-benar yang ada di depan.
Jebakan: akses yang dicabut kelihatan seperti hening
Kalau pengguna mematikan Usage access saat service berjalan, queryEvents tidak melempar exception. Ia cuma mengembalikan hasil kosong. Bacaan terakhirmu (“Instagram ada di depan”) jadi basi dan saldonya terkuras terus. Jadi setiap polling mengecek app-op dulu dan menghentikan jam kalau aksesnya sudah hilang:
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
}
Layar mati berarti berhenti
Saat layar mati, service menyimpan sesi yang sedang terbuka dan berhenti polling sama sekali. Tidak ada yang memakai aplikasi dengan layar mati, dan loop 1 Hz yang hanya berjalan saat kamu melihat HP itu murah. Polling pertama setelah layar menyala lagi juga menangani pergantian hari (reset tengah malam, jatah harian), jadi itu terjadi sebelum pengguna sampai ke aplikasi yang diblokir, bahkan kalau mereka tidak pernah membuka HonorFit.
Overlay layar kunci
Tanpa aksesibilitas tidak ada GLOBAL_ACTION_HOME, jadi kamu tidak bisa melempar pengguna keluar dari aplikasi. Sebagai gantinya kamu menutupinya: jendela TYPE_APPLICATION_OVERLAY dengan tombol “dapatkan waktu lagi” dan tombol “kembali ke layar utama”.
- Tutupi seluruh layar. Secara default, overlay-nya diberi inset oleh bilah sistem dan aplikasi yang diblokir mengintip di pinggirnya.
FLAG_LAYOUT_NO_LIMITSmemperbaikinya.FLAG_LAYOUT_IN_SCREENsudah deprecated di API 30, jadi tambahkan hanya di bawah R. - “Kembali ke layar utama” harus benar-benar ke layar utama. Menyembunyikan overlay cuma memperlihatkan aplikasi yang diblokir lagi, dan polling berikutnya memasang kuncinya kembali. Jalankan launcher sebagai gantinya:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - Jalankan dulu, baru sembunyikan. Tombol dapatkan waktu membuka HonorFit, dan itu artinya memulai activity dari latar belakang. Android mengizinkannya karena aplikasinya punya jendela overlay yang terlihat. Jadi mulai activity-nya selagi overlay masih tampil, lalu hapus overlay-nya. Kalau urutannya terbalik, peluncurannya diblokir diam-diam.
- Tidak ada izin overlay? Tetap hitung. Kalau “Display over other apps” tidak diberikan, service tetap menguras saldo dengan jujur. Ia cuma tidak bisa menggambar kuncinya sampai izinnya kembali.
Menjaga service tetap hidup
Bagian inilah yang paling lama untuk dibuat benar, dan sebagian besar bukan soal kodemu.
START_STICKY saja tidak cukup
“Aplikasi tidur” di Samsung, MIUI, OnePlus, dan kawan-kawannya mematikan service latar belakang lalu menelan restart sticky-nya. Aplikasi blokir adalah kasus terburuk untuk ini: orang tidak membukanya selama mereka masih punya waktu, jadi tidak ada yang sadar monitornya sudah hilang. HonorFit memasang alarm berulang yang inexact dan non-wakeup setiap 15 menit untuk menyalakan ulang service-nya. Alarm ini tidak pernah membangunkan HP, dan alarm yang tertunda selama layar mati akan berbunyi tidak lama setelah layar menyala lagi, tepat saat monitornya dibutuhkan.
Android 12+ tidak selalu mengizinkanmu memulainya
Memulai foreground service dari latar belakang (dari alarm, dari restart sticky) melempar ForegroundServiceStartNotAllowedException kecuali kamu punya pengecualian. Dua yang relevan di sini: pengguna sudah mematikan optimasi baterai untuk aplikasinya, atau aplikasinya punya SYSTEM_ALERT_WINDOW (yang ini cuma membantu sebelum Android 15). Jadi:
- Tangkap exception-nya dan mundur dengan tenang. Membuat prosesnya crash tidak membantu siapa pun, dan pembukaan aplikasi berikutnya akan memulai service-nya juga.
- Minta pengguna mengatur penggunaan baterai ke “Tidak dibatasi”. Pengaturan HonorFit punya kartu “Keandalan di latar belakang” yang memandu mereka ke layar yang tepat. Di beberapa HP, pengaturan itu yang menentukan jalan atau tidaknya.
Reboot dan update
Sebuah receiver untuk BOOT_COMPLETED dan MY_PACKAGE_REPLACED memulai ulang monitornya. Yang kedua gampang terlupa: update otomatis dari Play mematikan prosesmu dan tidak pernah memulai ulang service-nya, jadi tanpa itu setiap update diam-diam mematikan aplikasi blokirmu sampai pengguna membuka aplikasinya. Kedua broadcast itu dikecualikan dari pembatasan start dari latar belakang, dan receiver exported="false" tetap menerimanya.
Hal-hal kecil lainnya
- Beberapa aplikasi tidak boleh bisa diblokir: Pengaturan, jam (alarm!), dan Personal Safety, plus varian dari pabrikan seperti package jam Samsung. Saring semuanya dari daftar blokir di kode native, bukan cuma di pemilih aplikasi.
- Service-nya berjalan tanpa JS, jadi tidak bisa memanggil
t(). Aplikasinya mendorong teks overlay dan notifikasi keSharedPreferencesdalam bahasa yang sedang dipakai, dan service-nya memakai bahasa Inggris sampai teks itu tiba. - Sesi yang terbuka melewati restart service tidak bisa menguras saldo selama service-nya mati. Saat mulai, simpan waktu yang sudah berlalu (dibatasi) lalu mulai sesi baru, supaya tidak ada yang ditagih untuk waktu yang tidak sempat dilihat service-nya.
- Android 14+ membolehkan pengguna menggeser notifikasi foreground service sampai hilang. Service-nya terus memperbarui notifikasi itu (dengan dedupe supaya tidak memposting ulang setiap detik), jadi notifikasinya muncul lagi.
Kompromi yang diambil
Pendekatan ini lebih longgar dibanding aksesibilitas, dan aku mau terus terang soal itu:
- Aplikasi yang diblokir masih terlihat selama paling lama satu interval polling (sekitar satu detik) sebelum overlay-nya muncul.
- Aplikasi yang diblokir tetap berjalan di bawah overlay. Kalau ada video yang sedang diputar, suaranya masih terdengar sampai kamu mengetuk “kembali ke layar utama”.
- Semuanya bergantung pada pabrikan HP yang membiarkan service-mu tetap hidup, makanya pengaturan baterai itu penting sekali.
Untuk aplikasi blokir dengan sistem kejujuran, itu kompromi yang tepat. HonorFit tidak berusaha jadi tidak bisa dibobol. Orang yang memang niat curang bisa saja mencatat rep yang tidak mereka lakukan. Intinya adalah jeda dan harga, bukan penjara. Sebagai gantinya kamu mendapat layar izin yang bilang “akses penggunaan”, bukan “kontrol penuh”, review Play yang jauh lebih sederhana, dan service yang membaca persis satu hal: nama package aplikasi yang ada di depan.
Kalau kamu juga membangun untuk iOS, di sana dunianya benar-benar lain. Aku sudah menulis soal yang tidak diceritakan dokumentasi Screen Time Apple.
HonorFit Blocker adalah aplikasi blokir gratis untuk iPhone dan Android. Dapatkan screen time dengan push up, squat, atau latihan apa pun yang kamu pilih. Sistem kejujuran, tanpa kamera, tanpa akun, tidak ada yang keluar dari HP-mu.