Làm app chặn ứng dụng trên Android mà không cần AccessibilityService
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ả:
- Lời nhắc cấp quyền rất đáng sợ. Android báo cho người dùng rằng app có thể thấy mọi thứ trên màn hình và thao tác thay họ. Với một app mà lời giới thiệu là “không có gì rời khỏi điện thoại của bạn”, xin quyền đó ngay ngày đầu trông rất tệ, và nhiều người bỏ cuộc luôn ở bước đó.
- Google Play coi nó là API nhạy cảm. Nếu bạn không phải công cụ hỗ trợ tiếp cận, bạn cần một phần công bố nổi bật trong app, một bản khai báo trong Play Console, và một người duyệt đồng ý rằng mục đích sử dụng của bạn là ổn. Mình đã làm xong hộp thoại công bố đó. Nhưng mình thà không phải cần đến nó.
- Nó thấy nhiều hơn hẳn những gì một app chặn cần. Một app chặn chỉ cần đúng một thông tin: app nào đang ở phía trước.
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ền | Dù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_USE | Service 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:
ForegroundMonitorServicechỉ chạy khi có thứ cần thực thi (danh sách chặn không trống và đã được cấp Usage access). Mỗi giây một lần, và chỉ khi màn hình đang bật, nó hỏiUsageStatsManagerxem app nào vừa lên phía trước. Một app bị chặn còn thời gian sẽ trừ dần số dư đã tích; về 0 thì nó hiện lớp phủ.- Thông báo bắt buộc của nó chính là phần hiển thị hạn mức (“còn 12 phút”), nên thứ mà Android ép bạn phải có lại làm được việc thật.
- Toàn bộ trạng thái nằm trong
SharedPreferences, không nằm trong JS. Service phải tiếp tục chạy khi runtime React Native đã chết, mà phần lớn thời gian là vậy. Phía JS nạp thời gian và đọc số dư thông qua module; còn service nắm việc thực thi.
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”.
- Che toàn bộ màn hình. Mặc định lớp phủ bị thụt vào theo các thanh hệ thống và app bị chặn ló ra ở mép.
FLAG_LAYOUT_NO_LIMITSsửa được chuyện đó.FLAG_LAYOUT_IN_SCREENđã bị deprecated ở API 30, nên chỉ thêm nó khi dưới R. - “Về màn hình chính” phải về thật. Ẩn lớp phủ chỉ làm lộ lại app bị chặn, và lần poll tiếp theo lại khóa nó lần nữa. Thay vào đó hãy khởi chạy launcher:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - Khởi chạy trước, ẩn sau. Nút kiếm thêm thời gian mở HonorFit, tức là khởi chạy một activity từ nền. Android cho phép vì app đang có một cửa sổ lớp phủ hiển thị. Nên hãy khởi chạy activity trong lúc lớp phủ vẫn đang hiện, rồi mới gỡ nó đi. Làm ngược lại thì việc khởi chạy sẽ bị chặn mà không báo gì.
- Không có quyền lớp phủ? Vẫn cứ đếm. Nếu chưa cấp “Display over other apps”, service vẫn trừ số dư một cách trung thực. Nó chỉ không vẽ được màn hình khóa cho tới khi quyền được cấp lại.
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:
- Bắt exception và lặng lẽ thoát ra. Làm crash tiến trình chẳng giúp được ai, và lần mở app tiếp theo đằng nào cũng sẽ khởi động service.
- Nhờ người dùng đặt mức sử dụng pin thành “Không hạn chế”. Phần Cài đặt của HonorFit có thẻ “Chạy nền ổn định” dẫn họ tới đúng màn hình. Trên một số điện thoại, thiết lập đó quyết định app chạy được hay không.
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
- Một số app tuyệt đối không được phép chặn: Cài đặt, Đồng hồ (báo thức!) và Personal Safety, cộng với các biến thể của hãng như package đồng hồ của Samsung. Lọc chúng ra khỏi danh sách chặn ở code native, chứ không chỉ ở bộ chọn.
- Service chạy mà không có JS, nên không gọi được
t(). App đẩy các chuỗi của lớp phủ và thông báo vàoSharedPreferencestheo ngôn ngữ hiện tại, và service dùng tạm tiếng Anh cho tới khi các chuỗi đó tới nơi. - Một phiên còn mở khi service khởi động lại thì không thể bị trừ trong lúc service đã chết. Khi khởi động, tích phần thời gian đã trôi qua (có giới hạn) rồi bắt đầu một phiên mới, để không ai bị tính tiền cho khoảng thời gian service không có mặt để thấy.
- Android 14+ cho phép người dùng vuốt bỏ thông báo của foreground service. Service vẫn tiếp tục cập nhật thông báo (có chống trùng để không đăng lại mỗi giây), nên nó sẽ quay lại.
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 đó:
- App bị chặn vẫn hiện ra trong tối đa một chu kỳ poll (khoảng một giây) trước khi lớp phủ xuất hiện.
- App bị chặn vẫn chạy bên dưới lớp phủ. Nếu đang phát video, bạn vẫn nghe tiếng cho tới khi chạm “về màn hình chính”.
- Nó phụ thuộc vào việc hãng điện thoại để yên cho service của bạn, nên thiết lập pin mới quan trọng đến vậy.
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.