สร้างแอปบล็อกแอปบน Android โดยไม่ใช้ AccessibilityService
ผมทำ HonorFit แอปบล็อกแอปที่คุณต้องวิดพื้นเพื่อแลกเวลาหน้าจอ (ระบบความซื่อสัตย์ ไม่ใช้กล้อง) เวอร์ชัน Android แรกบล็อกแอปแบบเดียวกับแอปบล็อกบน Android ส่วนใหญ่: ใช้ AccessibilityService พอเดือนสิงหาคม ผมรื้อออกทั้งหมด แล้วเปลี่ยนไปใช้ UsageStatsManager, foreground service และหน้าต่าง overlay แทน การบล็อกนุ่มลงนิดหน่อย ขั้นตอนเริ่มต้นใช้งานน่ากลัวน้อยลงเยอะ และการรีวิวของ Play ก็ง่ายขึ้นมาก
นี่คือหน้าตาของสถาปัตยกรรมนั้น และเรื่องที่ทำผมเจ็บตัวระหว่างทาง โค้ดทั้งหมดเป็น Kotlin ใน Expo module แบบ local แต่ไม่มีอะไรในนี้ที่เฉพาะกับ React Native เลย
ทำไมถึงเลิกใช้ Accessibility API
accessibility service เป็นเครื่องมือที่ทรงพลังที่สุดที่คุณได้บน Android คุณรู้ทุกครั้งที่หน้าต่างเปลี่ยนทันทีที่มันเกิดขึ้น และ performGlobalAction(GLOBAL_ACTION_HOME) ก็เตะผู้ใช้ออกจากแอปที่ถูกบล็อกได้ทันที นั่นคือเหตุผลที่แอปบล็อกจำนวนมากใช้มัน ส่วนต้นทุนคือ:
- หน้าขอสิทธิ์น่ากลัวมาก Android บอกผู้ใช้ว่าแอปนี้เห็นทุกอย่างบนหน้าจอและทำงานแทนเขาได้ สำหรับแอปที่ขายจุดเด่นว่า “ไม่มีข้อมูลออกจากมือถือคุณ” การขอสิทธิ์แบบนั้นตั้งแต่วันแรกดูไม่ดีเลย และหลายคนก็เลิกตรงนั้นทันที
- Google Play ถือว่ามันเป็น API ที่อ่อนไหว ถ้าคุณไม่ใช่เครื่องมือช่วยการเข้าถึง คุณต้องมีหน้าเปิดเผยข้อมูลที่เห็นชัดในแอป การประกาศใน Play Console และผู้รีวิวที่เห็นด้วยว่ากรณีการใช้งานของคุณโอเค ผมสร้างหน้าต่างเปิดเผยข้อมูลไว้แล้วด้วยซ้ำ แต่ถ้าไม่ต้องใช้เลยจะดีกว่า
- มันเห็นมากกว่าที่แอปบล็อกต้องการเยอะ แอปบล็อกต้องการรู้แค่อย่างเดียว: แอปไหนอยู่ข้างหน้า
และข้อมูลนั้นหาได้จากที่อื่น: usage event ที่อยู่เบื้องหลัง Digital Wellbeing
โครงสร้างโดยรวม
สิทธิ์สามอย่าง ผู้ใช้เป็นคนให้เองทั้งหมดในการตั้งค่า:
| สิทธิ์ | ใช้ทำอะไร |
|---|---|
PACKAGE_USAGE_STATS (“สิทธิ์เข้าถึงการใช้งาน”) | อ่าน UsageEvents เพื่อรู้ว่าแอปไหนอยู่ข้างหน้า สิทธิ์นี้ขอตอน runtime ไม่ได้ ต้องส่งผู้ใช้ไปที่หน้าการตั้งค่าแล้วเช็ก app-op เอา |
SYSTEM_ALERT_WINDOW (“แสดงทับแอปอื่น”) | วาดหน้าจอล็อกทับแอปที่ถูกบล็อก |
FOREGROUND_SERVICE_SPECIAL_USE | service ที่คอยตรวจจับ การบล็อกแอปไม่เข้ากับประเภท foreground service ที่มีให้เลือกเลย จึงต้องเป็น specialUse พร้อมสตริง subtype ที่อธิบายเหตุผล |
ส่วนประกอบต่างๆ:
ForegroundMonitorServiceทำงานเฉพาะตอนที่มีอะไรต้องบังคับใช้ (blocklist ไม่ว่าง และได้สิทธิ์เข้าถึงการใช้งานแล้ว) มันจะถามUsageStatsManagerทุกวินาทีว่าแอปไหนขึ้นมาอยู่ข้างหน้า และทำแค่ตอนที่หน้าจอเปิดอยู่ แอปที่ถูกบล็อกแต่ยังมีเวลาเหลือจะหักเวลาที่เก็บไว้ พอเหลือศูนย์ก็แสดง overlay- การแจ้งเตือนที่บังคับต้องมีของมันคือตัวแสดงโควตา (“เหลือ 12 นาที”) สิ่งที่ Android บังคับให้มีเลยได้ทำงานจริงๆ ด้วย
- state ทั้งหมดอยู่ใน
SharedPreferencesไม่ได้อยู่ใน JS service ต้องทำงานต่อได้ตอนที่ runtime ของ React Native ตายไปแล้ว ซึ่งก็คือเกือบตลอดเวลา ฝั่ง JS ฝากเวลาและอ่านยอดคงเหลือผ่าน module ส่วนการบังคับใช้เป็นหน้าที่ของ service
รายการของ service ใน 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>
คุณยังต้องอธิบายเหตุผลของ specialUse ในการประกาศ foreground service ของ Play Console ด้วย เล่าเรื่องเดียวกันทั้งสองที่จะช่วยได้
การ poll UsageStats (และความล่าช้าในการเขียน)
ลูปหลักเล็กนิดเดียว สแกน event ตั้งแต่การ poll ครั้งก่อน แล้วเก็บ package ของ MOVE_TO_FOREGROUND ตัวสุดท้ายไว้ (มันเป็นค่าคงที่เดียวกับ ACTIVITY_RESUMED ที่เปลี่ยนชื่อใน 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
}
}
// รอบหน้าสแกน 15 วินาทีล่าสุดซ้ำ: ดูด้านล่าง
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)
ข้อดีที่ได้แถมมาเมื่อเทียบกับ accessibility event: คีย์บอร์ดกับแถบการแจ้งเตือนไม่ใช่ activity เลยไม่เคยโผล่มาเป็น “ผู้ใช้ออกจากแอปแล้ว” แบบหลอกๆ
จุดที่ต้องระวัง: event มาช้า
เวอร์ชันแรกของผมเลื่อนหน้าต่างเวลาไปข้างหน้าอย่างเดียว: poll แต่ละครั้ง query [lastEnd, now] ส่วนใหญ่ก็ใช้ได้ แต่บนมือถือที่เพิ่งเปิดเครื่องใหม่ การเปิดแอปที่ถูกบล็อกครั้งแรกสุดไม่ถูกเห็นเลย UsageStats เขียน event ลงที่เก็บข้อมูลแบบล่าช้า event ที่ประทับเวลา 10:00:00.900 เลยอาจโผล่มาหลังจาก poll ที่ครอบคลุมถึง 10:00:01 ไปแล้ว หน้าต่างที่เลื่อนไปข้างหน้าอย่างเดียวจะข้ามมันไปตลอดกาล
วิธีแก้: poll ทุกครั้งสแกนช่วงที่ซ้อนทับย้อนหลังซ้ำ (ผมใช้ 15 วินาที) การเห็น event เดิมสองครั้งไม่มีผลเสีย เพราะยังไง resume ครั้งสุดท้ายในหน้าต่างก็ชนะอยู่ดี event ที่มาช้ากว่า 15 วินาทียังหลุดได้ แต่ผมยังไม่เคยเจอ
จุดที่ต้องระวัง: poll แรกต้องมองย้อนไปไกล
ตอนที่ service เริ่มทำงาน (หลังรีสตาร์ต หรือตอนหน้าจอเปิด) ยังไม่มี “แอปล่าสุดที่รู้” ถ้าผู้ใช้นั่งอยู่ใน TikTok มา 20 นาทีแล้ว และ poll แรกมองย้อนแค่หนึ่งนาที มันจะไม่เห็นอะไรเลยจนกว่าเขาจะสลับแอป poll แรกจึงสแกนย้อนหลังหนึ่งชั่วโมง ซึ่งปลอดภัยเพราะ launcher ก็เป็น activity เหมือนกัน: อะไรที่ resume ล่าสุดก็คือสิ่งที่อยู่ข้างหน้าจริงๆ
จุดที่ต้องระวัง: สิทธิ์ที่ถูกถอนดูเหมือนความเงียบ
ถ้าผู้ใช้ปิดสิทธิ์เข้าถึงการใช้งานระหว่างที่ service ทำงานอยู่ queryEvents จะไม่ throw แต่จะคืนค่าว่างเปล่า ค่าที่อ่านได้ล่าสุด (“Instagram อยู่ข้างหน้า”) ก็จะค้างอยู่ และยอดคงเหลือก็ถูกหักไปเรื่อยๆ ไม่มีวันจบ poll ทุกครั้งเลยเช็ก app-op ก่อน และหยุดนับเวลาถ้าสิทธิ์หายไป:
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
}
หน้าจอดับคือหยุด
พอหน้าจอดับ service จะสรุปเวลาของ session ที่เปิดอยู่ แล้วหยุด poll ไปเลย ไม่มีใครใช้แอปตอนหน้าจอดับ และลูป 1 Hz ที่ทำงานเฉพาะตอนคุณมองมือถืออยู่ก็กินทรัพยากรน้อย poll แรกหลังหน้าจอเปิดยังจัดการเรื่องขึ้นวันใหม่ด้วย (รีเซ็ตตอนเที่ยงคืน, เวลาพื้นฐานรายวัน) มันเลยเกิดขึ้นก่อนที่ผู้ใช้จะไปถึงแอปที่ถูกบล็อก แม้ว่าเขาจะไม่เคยเปิด HonorFit เลยก็ตาม
overlay สำหรับล็อก
ถ้าไม่มี accessibility ก็ไม่มี GLOBAL_ACTION_HOME คุณจึงโยนผู้ใช้ออกจากแอปไม่ได้ ต้องปิดทับมันแทน: หน้าต่าง TYPE_APPLICATION_OVERLAY ที่มีปุ่ม “รับเวลาเพิ่ม” และปุ่ม “กลับหน้าหลัก”
- ปิดทับทั้งจอ โดยค่าเริ่มต้น overlay จะถูกเว้นขอบตามแถบระบบ และแอปที่ถูกบล็อกจะโผล่ออกมาตามขอบ
FLAG_LAYOUT_NO_LIMITSแก้ได้ ส่วนFLAG_LAYOUT_IN_SCREENถูก deprecate ใน API 30 แล้ว เลยใส่เฉพาะเวอร์ชันที่ต่ำกว่า R - “กลับหน้าหลัก” ต้องกลับหน้าหลักจริงๆ แค่ซ่อน overlay ก็จะเผยแอปที่ถูกบล็อกออกมาอีก แล้ว poll ถัดไปก็ใส่ล็อกกลับเข้าไป ให้เปิด launcher แทน:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - เปิดก่อน แล้วค่อยซ่อน ปุ่มรับเวลาเพิ่มจะเปิด HonorFit ซึ่งเป็นการเริ่ม activity จากเบื้องหลัง Android ยอมให้ทำได้เพราะแอปมีหน้าต่าง overlay ที่มองเห็นอยู่ เพราะฉะนั้นให้เริ่ม activity ตอนที่ overlay ยังแสดงอยู่ แล้วค่อยเอามันออก ถ้าทำกลับกัน การเปิดจะถูกบล็อกแบบเงียบๆ
- ไม่มีสิทธิ์ overlay? ก็นับต่อไป ถ้ายังไม่ได้ให้สิทธิ์ “แสดงทับแอปอื่น” service ก็ยังหักยอดคงเหลืออย่างซื่อตรง แค่วาดหน้าล็อกไม่ได้จนกว่าจะได้สิทธิ์กลับมา
ทำให้ service ไม่ตาย
นี่คือส่วนที่ใช้เวลานานที่สุดกว่าจะทำให้ถูก และส่วนใหญ่ไม่เกี่ยวกับโค้ดของคุณเลย
START_STICKY ไม่พอ
“แอปที่อยู่ในโหมดสลีป” ของ Samsung, MIUI, OnePlus และพวกพ้อง ฆ่า service ที่ทำงานเบื้องหลัง แล้วกลืนการรีสตาร์ตแบบ sticky ไปด้วย แอปบล็อกคือกรณีที่แย่ที่สุด: คนไม่เปิดมันตอนที่ยังมีเวลาเหลือ เลยไม่มีอะไรสังเกตว่าตัวตรวจจับหายไปแล้ว HonorFit ตั้ง alarm แบบซ้ำทุก 15 นาที ที่ไม่แม่นยำและไม่ปลุกเครื่อง เพื่อสั่งให้ service กลับมาทำงาน มันไม่เคยปลุกมือถือ และ alarm ที่ถูกเลื่อนไว้ตอนหน้าจอดับจะทำงานไม่นานหลังหน้าจอเปิด ซึ่งก็คือตอนที่ตัวตรวจจับสำคัญที่สุดพอดี
Android 12+ ไม่ได้ยอมให้คุณเริ่มมันเสมอ
การเริ่ม foreground service จากเบื้องหลัง (alarm, การรีสตาร์ตแบบ sticky) จะ throw ForegroundServiceStartNotAllowedException เว้นแต่คุณมีข้อยกเว้น มีสองข้อที่เกี่ยวกับกรณีนี้: ผู้ใช้ปิดการเพิ่มประสิทธิภาพแบตเตอรี่ให้แอปแล้ว หรือแอปถือสิทธิ์ SYSTEM_ALERT_WINDOW (ข้อนี้ช่วยได้แค่ก่อน Android 15) ดังนั้น:
- catch exception แล้วออกไปเงียบๆ ทำให้ process crash ไม่ช่วยใคร และการเปิดแอปครั้งถัดไปก็จะเริ่ม service อยู่ดี
- ขอให้ผู้ใช้ตั้งการใช้แบตเตอรี่เป็น “ไม่จำกัด” ในการตั้งค่าของ HonorFit มีการ์ด “การทำงานเบื้องหลัง” ที่พาไปถึงหน้าที่ถูกต้อง บนมือถือบางรุ่น การตั้งค่านี้คือความต่างระหว่างใช้ได้กับใช้ไม่ได้
รีบูตและอัปเดต
receiver สำหรับ BOOT_COMPLETED และ MY_PACKAGE_REPLACED จะเริ่มการตรวจจับใหม่ ตัวที่สองลืมง่าย: การอัปเดตอัตโนมัติของ Play จะฆ่า process ของคุณและไม่เคยรีสตาร์ต service ให้ ถ้าไม่มีมัน ทุกการอัปเดตจะปิดแอปบล็อกของคุณเงียบๆ จนกว่าผู้ใช้จะเปิดแอป broadcast ทั้งสองได้รับยกเว้นจากข้อจำกัดการเริ่มจากเบื้องหลัง และ receiver ที่ exported="false" ก็ยังได้รับอยู่
เรื่องเล็กๆ อื่นๆ
- บางแอปต้องบล็อกไม่ได้เด็ดขาด: การตั้งค่า, นาฬิกา (นาฬิกาปลุก!) และ Personal Safety รวมถึงเวอร์ชันของแต่ละแบรนด์ อย่าง package นาฬิกาของ Samsung กรองพวกนี้ออกจาก blocklist ในโค้ด native ไม่ใช่แค่ในตัวเลือกแอป
- service ทำงานโดยไม่มี JS เลยเรียก
t()ไม่ได้ แอปจะส่งสตริงของ overlay และการแจ้งเตือนเป็นภาษาปัจจุบันเข้าไปในSharedPreferencesและ service จะใช้ภาษาอังกฤษไปก่อนจนกว่าสตริงจะมาถึง - session ที่เปิดค้างไว้ข้ามการรีสตาร์ตของ service หักเวลาไม่ได้ในช่วงที่ service ตายอยู่ ตอนเริ่มทำงาน ให้สรุปเวลาที่ผ่านไป (แบบมีเพดาน) แล้วเริ่ม session ใหม่ จะได้ไม่มีใครโดนหักเวลาในช่วงที่ service ไม่ได้อยู่ดู
- Android 14+ ให้ผู้ใช้ปัดการแจ้งเตือนของ foreground service ทิ้งได้ service จะอัปเดตมันต่อไปเรื่อยๆ (มีการกันซ้ำเพื่อไม่ให้โพสต์ใหม่ทุกวินาที) มันเลยกลับมา
สิ่งที่ต้องแลก
วิธีนี้นุ่มกว่า accessibility และผมอยากพูดตรงๆ เรื่องนี้:
- แอปที่ถูกบล็อกจะมองเห็นได้นานสุดหนึ่งรอบ poll (ประมาณหนึ่งวินาที) ก่อนที่ overlay จะขึ้น
- แอปที่ถูกบล็อกยังทำงานอยู่ใต้ overlay ถ้ามีวิดีโอเล่นอยู่ คุณจะยังได้ยินเสียงจนกว่าจะแตะ “กลับหน้าหลัก”
- มันขึ้นอยู่กับว่าแบรนด์มือถือจะปล่อย service ของคุณไว้หรือเปล่า นั่นคือเหตุผลที่การตั้งค่าแบตเตอรี่สำคัญมาก
สำหรับแอปบล็อกที่ใช้ระบบความซื่อสัตย์ นี่คือการแลกที่ถูกต้อง HonorFit ไม่ได้พยายามจะเป็นสิ่งที่ทำลายไม่ได้ คนที่ตั้งใจจะโกงก็แค่บันทึกครั้งที่ไม่ได้ทำ จุดประสงค์คือการหยุดคิดสักนิดและมีราคาที่ต้องจ่าย ไม่ใช่คุก สิ่งที่ได้กลับมาคือหน้าขอสิทธิ์ที่เขียนว่า “สิทธิ์เข้าถึงการใช้งาน” แทนที่จะเป็น “ควบคุมได้ทั้งหมด” การรีวิวของ Play ที่ง่ายกว่าเยอะ และ service ที่อ่านแค่อย่างเดียว: ชื่อ package ของแอปที่อยู่ข้างหน้า
ถ้าคุณทำบน iOS ด้วย ฝั่งนั้นเป็นอีกโลกหนึ่งเลย ผมเขียนเรื่องสิ่งที่เอกสาร Screen Time ของ Apple ไม่ได้บอกคุณไว้แล้ว
HonorFit Blocker คือแอปบล็อกแอปที่ใช้ฟรีทั้ง iPhone และ Android แลกเวลาหน้าจอด้วยการวิดพื้น สควอท หรือท่าไหนก็ได้ที่คุณเลือก ใช้ระบบความซื่อสัตย์ ไม่ใช้กล้อง ไม่ต้องมีบัญชี ไม่มีข้อมูลออกจากมือถือคุณ