Làm app chặn ứng dụng trên Android mà không cần AccessibilityService

UsageStatsManager, một foreground service và một lớp phủ, rút ra từ một app đang phát hành. Bài của Thomas Willheim · 9 tháng 11, 2026

Mình làm HonorFit, một app chặn ứng dụng mà bạn kiếm thời gian sử dụng màn hình bằng cách hít đất (cơ chế tự giác, không camera). Bản Android đầu tiên chặn ứng dụng theo cách mà phần lớn app chặn trên Android vẫn làm: dùng AccessibilityService. Hồi tháng 8, mình gỡ bỏ hết phần đó và thay bằng UsageStatsManager, một foreground service và một cửa sổ lớp phủ. Việc chặn mềm hơn một chút, phần onboarding bớt đáng sợ đi rất nhiều, và việc duyệt trên Play cũng đơn giản hơn hẳn.

Đây là kiến trúc đó trông ra sao và những chỗ mình bị vấp trên đường đi. Toàn bộ code là Kotlin bên trong một Expo module cục bộ, nhưng không có gì ở đây là riêng cho React Native cả.

Vì sao bỏ Accessibility API

Accessibility service là công cụ mạnh nhất bạn có trên Android. Bạn được báo về mọi thay đổi cửa sổ ngay khi nó xảy ra, và performGlobalAction(GLOBAL_ACTION_HOME) đá người dùng ra khỏi app bị chặn ngay lập tức. Vì vậy mà rất nhiều app chặn dùng nó. Cái giá phải trả:

Thông tin đó có sẵn ở chỗ khác: các sự kiện sử dụng đứng sau Digital Wellbeing.

Hình dạng tổng thể

Ba quyền, tất cả đều do người dùng cấp trong Cài đặt:

QuyềnDùng để làm gì
PACKAGE_USAGE_STATS (“Usage access”)Đọc UsageEvents để biết app nào đang ở phía trước. Không bao giờ được cấp lúc runtime; bạn đưa người dùng tới màn hình Cài đặt rồi kiểm tra app-op.
SYSTEM_ALERT_WINDOW (“Display over other apps”)Vẽ màn hình khóa đè lên app bị chặn.
FOREGROUND_SERVICE_SPECIAL_USEService theo dõi. Việc chặn ứng dụng không khớp với loại foreground service nào trong danh sách, nên nó là specialUse kèm một chuỗi subtype giải thích lý do.

Các thành phần:

Khai báo service trong 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>

Bạn cũng phải giải trình specialUse trong phần khai báo foreground service của Play Console. Kể cùng một câu chuyện ở cả hai nơi sẽ có ích.

Polling UsageStats (và độ trễ ghi của nó)

Vòng lặp chính khá nhỏ. Quét các sự kiện kể từ lần poll trước và giữ lại package MOVE_TO_FOREGROUND cuối cùng (đây cùng là hằng số ACTIVITY_RESUMED, chỉ đổi tên ở 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
  }
}
// Lần sau quét lại 15 giây cuối: xem bên dưới.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)

Một lợi ích phụ so với sự kiện accessibility: bàn phím và khay thông báo không phải là activity, nên chúng không bao giờ hiện ra như những lần “người dùng rời app” giả.

Bẫy: sự kiện đến muộn

Bản đầu tiên của mình chỉ đẩy cửa sổ thời gian tiến về phía trước: mỗi lần poll query [lastEnd, now]. Phần lớn thời gian thì chạy ổn, rồi trên một chiếc điện thoại vừa khởi động lạnh, lần mở app bị chặn đầu tiên không bao giờ được nhìn thấy. UsageStats ghi sự kiện vào kho dữ liệu của nó có độ trễ, nên một sự kiện đóng dấu 10:00:00.900 có thể xuất hiện sau một lần poll đã bao trùm tới 10:00:01. Một cửa sổ chỉ tiến về phía trước sẽ bỏ sót nó mãi mãi.

Cách sửa: mỗi lần poll quét lại một đoạn chồng lấn phía sau (mình dùng 15 giây). Thấy cùng một sự kiện hai lần cũng chẳng hại gì, vì lần resume cuối cùng trong cửa sổ đằng nào cũng thắng. Một sự kiện đến muộn hơn 15 giây vẫn sẽ bị mất, nhưng mình chưa gặp trường hợp nào.

Bẫy: lần poll đầu cần nhìn lại xa

Khi service khởi động (sau khi khởi động lại, hoặc khi màn hình bật lên) chưa có “app cuối cùng được biết”. Nếu người dùng đã ngồi trong TikTok 20 phút mà lần poll đầu chỉ nhìn lại một phút, nó sẽ không thấy gì cho tới khi họ chuyển app. Vì vậy lần poll đầu quét cả tiếng vừa qua. Làm vậy là an toàn vì launcher cũng là một activity: thứ gì resume cuối cùng thì đúng là thứ đang ở phía trước.

Bẫy: bị thu hồi quyền trông giống như im lặng

Nếu người dùng tắt Usage access trong lúc service đang chạy, queryEvents không ném lỗi. Nó chỉ trả về rỗng. Lần đọc cuối của bạn (“Instagram đang ở phía trước”) bị cũ và số dư bị trừ mãi. Vì vậy mỗi lần poll đều kiểm tra app-op trước và dừng đồng hồ nếu quyền đã mất:

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
}

Tắt màn hình nghĩa là dừng

Khi màn hình tắt, service tích phiên đang mở và ngừng poll hoàn toàn. Chẳng ai dùng app khi màn hình đã tắt, và một vòng lặp 1 Hz chỉ chạy khi bạn đang nhìn điện thoại thì rất rẻ. Lần poll đầu tiên sau khi màn hình bật lại cũng xử lý việc sang ngày mới (đặt lại lúc nửa đêm, mức cơ bản mỗi ngày), nên việc đó xảy ra trước khi người dùng mở tới app bị chặn, kể cả khi họ chưa từng mở HonorFit.

Lớp phủ khóa màn hình

Không có accessibility thì không có GLOBAL_ACTION_HOME, nên bạn không thể ném người dùng ra khỏi app. Thay vào đó, bạn che nó lại: một cửa sổ TYPE_APPLICATION_OVERLAY với nút “kiếm thêm thời gian” và nút “về màn hình chính”.

Giữ cho service luôn chạy

Đây là phần tốn thời gian nhất để làm cho đúng, và phần lớn chẳng liên quan gì tới code của bạn.

START_STICKY là chưa đủ

“Ứng dụng ngủ” của Samsung, MIUI, OnePlus và các hãng tương tự diệt service chạy nền rồi nuốt luôn lần khởi động lại sticky. Một app chặn là trường hợp tệ nhất cho chuyện này: người ta không mở nó khi còn thời gian, nên chẳng có gì nhận ra trình theo dõi đã biến mất. HonorFit đặt một alarm lặp lại kiểu inexact, non-wakeup mỗi 15 phút để khởi động lại service. Nó không bao giờ đánh thức điện thoại, và các alarm bị hoãn trong lúc màn hình tắt sẽ chạy ngay sau khi màn hình bật lại, đúng lúc trình theo dõi cần có mặt.

Android 12+ không phải lúc nào cũng cho bạn khởi động nó

Khởi động một foreground service từ nền (từ alarm, từ lần khởi động lại sticky) sẽ ném ForegroundServiceStartNotAllowedException trừ khi bạn có ngoại lệ. Hai ngoại lệ quan trọng ở đây: người dùng đã tắt tối ưu hóa pin cho app, hoặc app có quyền SYSTEM_ALERT_WINDOW (cái này chỉ có tác dụng trước Android 15). Vậy nên:

Khởi động lại máy và cập nhật

Một receiver cho BOOT_COMPLETED và MY_PACKAGE_REPLACED khởi động lại việc theo dõi. Cái thứ hai rất dễ quên: một lần tự động cập nhật từ Play sẽ giết tiến trình của bạn và không bao giờ khởi động lại các service của nó, nên nếu thiếu nó, mỗi lần cập nhật sẽ âm thầm tắt app chặn của bạn cho tới khi người dùng mở app. Cả hai broadcast đều được miễn khỏi giới hạn khởi chạy từ nền, và receiver exported="false" vẫn nhận được chúng.

Những thứ nhỏ hơn

Những đánh đổi

Cách làm này mềm hơn accessibility, và mình muốn nói thẳng điều đó:

Với một app chặn theo cơ chế tự giác, đó là đánh đổi đúng đắn. HonorFit không cố trở thành thứ không thể phá. Ai quyết tâm gian lận thì cứ việc ghi những lần tập mình chưa làm. Mục đích là một khoảng dừng và một cái giá, chứ không phải nhà tù. Đổi lại, bạn có một màn hình cấp quyền ghi “quyền truy cập dữ liệu sử dụng” thay vì “toàn quyền kiểm soát”, việc duyệt trên Play đơn giản hơn nhiều, và một service chỉ đọc đúng một thứ: tên package của app đang ở phía trước.

Nếu bạn cũng làm cho iOS thì bên đó là một thế giới hoàn toàn khác. Mình đã viết về những điều tài liệu Screen Time của Apple không nói.

HonorFit Blocker là app chặn ứng dụng miễn phí cho iPhone và Android. Kiếm thời gian dùng màn hình bằng hít đất, squat hay bất cứ bài tập nào bạn chọn. Cơ chế tự giác, không camera, không tài khoản, không có gì rời khỏi điện thoại của bạn.

Đọc tiếp