AccessibilityService 없이 Android 앱 차단 앱 만들기
저는 팔굽혀펴기를 해서 스크린 타임을 버는 앱 차단 앱 HonorFit을 만들어요(양심 시스템, 카메라 없음). 첫 Android 버전은 대부분의 Android 차단 앱처럼 AccessibilityService로 앱을 차단했어요. 8월에 그걸 통째로 뜯어내고 UsageStatsManager, 포그라운드 서비스, 오버레이 창으로 바꿨어요. 차단은 조금 느슨해졌지만, 온보딩은 훨씬 덜 무서워졌고 Play 심사도 훨씬 단순해졌어요.
이 글은 그 구조가 어떻게 생겼는지, 그리고 그 과정에서 제가 당한 것들에 대한 이야기예요. 코드는 전부 로컬 Expo 모듈 안의 Kotlin이지만, React Native에만 해당하는 내용은 하나도 없어요.
접근성 API를 버린 이유
접근성 서비스는 Android에서 쓸 수 있는 가장 강력한 도구예요. 창이 바뀌는 순간 바로 알 수 있고, performGlobalAction(GLOBAL_ACTION_HOME)으로 차단한 앱에서 사용자를 그 자리에서 쫓아낼 수 있어요. 그래서 많은 차단 앱이 이걸 써요. 대가는 이래요.
- 권한 요청 화면이 무시무시해요. Android는 사용자에게 이 앱이 화면의 모든 것을 볼 수 있고 사용자 대신 동작을 할 수 있다고 알려요. ‘아무것도 폰 밖으로 나가지 않는다’를 내세우는 앱이 첫날부터 그걸 요구하면 모양이 안 좋고, 많은 사람이 바로 거기서 그만둬요.
- Google Play는 이걸 민감한 API로 취급해요. 접근성 도구가 아니라면 눈에 잘 띄는 앱 내 고지, Play Console 신고, 그리고 그 용도가 괜찮다고 판단해 줄 심사자가 필요해요. 고지 모달은 이미 만들어 뒀어요. 그래도 안 쓸 수 있으면 그게 낫죠.
- 차단 앱에 필요한 것보다 훨씬 많은 걸 봐요. 차단 앱에 필요한 사실은 딱 하나, 어떤 앱이 포그라운드에 있느냐예요.
그 사실 하나는 다른 데서도 얻을 수 있어요. 디지털 웰빙 뒤에 있는 사용 이벤트예요.
전체 구조
권한은 세 가지이고, 모두 사용자가 설정에서 허용해요.
| 권한 | 용도 |
|---|---|
PACKAGE_USAGE_STATS (‘사용 기록 액세스’) | UsageEvents를 읽어서 어떤 앱이 앞에 있는지 알아내요. 런타임에 허용되는 권한이 아니라서, 사용자를 설정 화면으로 보내고 app-op를 확인해요. |
SYSTEM_ALERT_WINDOW (‘다른 앱 위에 표시’) | 차단한 앱 위에 잠금 화면을 그려요. |
FOREGROUND_SERVICE_SPECIAL_USE | 모니터 서비스용이에요. 앱 차단은 정해진 포그라운드 서비스 유형 어디에도 맞지 않아서, specialUse로 하고 이유를 설명하는 서브타입 문자열을 붙여요. |
구성 요소는 이래요.
ForegroundMonitorService는 강제할 게 있을 때(차단 목록이 비어 있지 않고 사용 기록 액세스가 허용됐을 때)만 돌아요. 1초에 한 번, 그것도 화면이 켜져 있을 때만UsageStatsManager에 무엇이 포그라운드로 왔는지 물어봐요. 시간이 남은 차단 앱이면 적립된 잔액을 깎고, 0이 되면 오버레이를 띄워요.- 필수 알림이 곧 남은 시간 표시예요(‘12분 남음’). Android가 억지로 붙이는 걸 제대로 일하게 만드는 거죠.
- 모든 상태는 JS가 아니라
SharedPreferences에 있어요. 서비스는 React Native 런타임이 죽어 있을 때도, 그러니까 거의 항상 계속 돌아가야 하거든요. JS 쪽은 모듈을 통해 시간을 입금하고 잔액을 읽기만 하고, 강제는 서비스가 맡아요.
서비스의 매니페스트 항목이에요.
<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>
Play Console의 포그라운드 서비스 신고에서도 specialUse를 정당화해야 해요. 두 군데에서 같은 이야기를 하면 도움이 돼요.
UsageStats 폴링(과 쓰기 지연)
핵심 루프는 작아요. 마지막 폴링 이후의 이벤트를 훑고, 마지막 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
}
}
// Re-scan the last 15 s next time: see below.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)
접근성 이벤트에 비해 좋은 부수 효과도 있어요. 키보드와 알림 창은 액티비티가 아니라서, ‘사용자가 앱을 나갔다’는 가짜 이탈로 잡히지 않아요.
함정: 이벤트가 늦게 도착해요
첫 버전에서는 창을 엄격하게 앞으로만 옮겼어요. 폴링할 때마다 [lastEnd, now]를 조회했죠. 대체로 잘 됐는데, 콜드 스타트한 폰에서 차단 앱을 처음 연 게 전혀 잡히지 않았어요. UsageStats는 이벤트를 저장소에 늦게 써요. 그래서 10:00:00.900이 찍힌 이벤트가 이미 10:00:01까지 훑은 폴링 뒤에 나타날 수 있어요. 엄격하게 앞으로만 가는 창은 그 이벤트를 영원히 건너뛰어요.
해결책: 폴링할 때마다 바로 앞 구간(저는 15초)을 겹쳐서 다시 훑어요. 같은 이벤트를 두 번 봐도 문제없어요. 어느 쪽이든 창 안의 마지막 resume이 이기니까요. 15초보다 더 늦게 도착한 이벤트는 여전히 놓치지만, 아직 그런 건 못 봤어요.
함정: 첫 폴링은 멀리 거슬러 봐야 해요
서비스가 시작될 때(재시작 후나 화면이 켜질 때)는 ‘마지막으로 알던 앱’이 없어요. 사용자가 틱톡에 20분째 있는데 첫 폴링이 1분만 거슬러 보면, 앱을 바꿀 때까지 아무것도 안 보여요. 그래서 첫 폴링은 지난 1시간을 훑어요. 런처도 액티비티라서 이렇게 해도 안전해요. 마지막으로 resume된 게 정말로 앞에 있는 앱이에요.
함정: 권한을 끄면 그냥 조용해 보여요
서비스가 도는 중에 사용자가 사용 기록 액세스를 끄면, queryEvents는 예외를 던지지 않아요. 그냥 아무것도 안 돌려줘요. 마지막으로 읽은 값(‘인스타그램이 앞에 있음’)이 낡은 채로 남고, 잔액은 끝없이 깎여요. 그래서 폴링할 때마다 먼저 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
}
화면이 꺼지면 멈추기
화면이 꺼지면 서비스는 열려 있던 세션을 정산하고 폴링을 완전히 멈춰요. 화면을 끈 채로 앱을 쓰는 사람은 없고, 폰을 보고 있을 때만 도는 1 Hz 루프는 부담이 작아요. 화면이 다시 켜진 뒤 첫 폴링에서는 날짜 넘어가기(자정 초기화, 기본 시간)도 처리해요. 그래서 HonorFit을 한 번도 열지 않더라도, 사용자가 차단한 앱에 닿기 전에 처리가 끝나 있어요.
잠금 오버레이
접근성이 없으면 GLOBAL_ACTION_HOME도 없으니까, 사용자를 앱에서 쫓아낼 수 없어요. 대신 덮어요. ‘시간 더 얻기’ 버튼과 ‘홈으로 가기’ 버튼이 있는 TYPE_APPLICATION_OVERLAY 창이에요.
- 화면 전체를 덮으세요. 기본적으로 오버레이는 시스템 바만큼 안쪽으로 들어가서, 차단한 앱이 가장자리로 삐져나와 보여요.
FLAG_LAYOUT_NO_LIMITS로 해결돼요.FLAG_LAYOUT_IN_SCREEN은 API 30에서 지원 중단됐으니 R 미만에서만 추가하세요. - ‘홈으로 가기’는 정말로 홈으로 가야 해요. 오버레이를 숨기기만 하면 차단한 앱이 다시 보이고, 다음 폴링에서 잠금이 다시 걸려요. 대신 런처를 실행하세요.
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - 실행하고 나서 숨기세요. 시간 더 얻기 버튼은 HonorFit을 여는데, 이건 백그라운드에서의 액티비티 시작이에요. 앱에 보이는 오버레이 창이 있어서 Android가 허용해 줘요. 그러니까 오버레이가 아직 떠 있는 동안 액티비티를 시작하고, 그다음에 오버레이를 치우세요. 순서를 거꾸로 하면 실행이 조용히 막혀요.
- 오버레이 권한이 없어도 계속 세세요. ‘다른 앱 위에 표시’가 허용되지 않았더라도, 서비스는 정직하게 잔액을 계속 깎아요. 권한이 돌아올 때까지 잠금을 그리지 못할 뿐이에요.
서비스 살려 두기
제대로 하기까지 제일 오래 걸리는 부분이고, 대부분은 내 코드의 문제가 아니에요.
START_STICKY로는 부족해요
삼성의 ‘절전 상태 앱’, MIUI, OnePlus 같은 것들은 백그라운드 서비스를 죽이고, sticky 재시작까지 삼켜 버려요. 차단 앱한테는 최악의 경우예요. 시간이 남아 있는 동안에는 아무도 앱을 열지 않으니까, 모니터가 사라진 걸 아무도 눈치채지 못해요. HonorFit은 15분마다 서비스를 다시 세우는, inexact하고 non-wakeup인 반복 알람을 걸어 둬요. 폰을 깨우는 일은 없고, 화면이 꺼져 있는 동안 미뤄진 알람은 화면이 다시 켜지면 곧 울려요. 모니터가 중요한 때가 바로 그때예요.
Android 12 이상에서는 시작을 못 하게 할 때가 있어요
백그라운드(알람, sticky 재시작)에서 포그라운드 서비스를 시작하면, 예외 조건에 해당하지 않는 한 ForegroundServiceStartNotAllowedException이 던져져요. 여기서 중요한 예외는 두 가지예요. 사용자가 앱의 배터리 최적화를 껐거나, 앱이 SYSTEM_ALERT_WINDOW를 가지고 있는 경우(이건 Android 15 전까지만 통해요). 그래서 이렇게 해요.
- 예외를 잡고 조용히 빠져나와요. 프로세스를 죽여 봐야 아무도 득 볼 게 없고, 다음에 앱을 열면 어차피 서비스가 시작돼요.
- 사용자에게 배터리 사용을 ‘제한 없음’으로 바꿔 달라고 해요. HonorFit 설정에는 ‘백그라운드 안정성’ 카드가 있어서 맞는 화면까지 안내해 줘요. 어떤 폰에서는 이 설정이 되느냐 안 되느냐를 갈라요.
재부팅과 업데이트
BOOT_COMPLETED와 MY_PACKAGE_REPLACED 리시버가 모니터링을 다시 시작해요. 두 번째는 잊기 쉬워요. Play 자동 업데이트는 프로세스를 죽이고 서비스를 다시 시작해 주지 않아요. 그래서 이게 없으면 업데이트할 때마다, 사용자가 앱을 열 때까지 차단 앱이 조용히 꺼져 있어요. 두 브로드캐스트 모두 백그라운드 시작 제한에서 예외이고, exported="false" 리시버도 받을 수 있어요.
자잘한 것들
- 절대 차단되면 안 되는 앱이 있어요: 설정, 시계(알람!), Personal Safety, 그리고 삼성 시계 패키지 같은 제조사 버전들이요. 선택기에서만이 아니라 네이티브 코드에서도 차단 목록에서 걸러 내세요.
- 서비스는 JS 없이 돌아가서
t()를 부를 수 없어요. 앱이 오버레이와 알림 문자열을 현재 언어로SharedPreferences에 밀어 넣고, 그게 도착할 때까지 서비스는 영어로 대체해요. - 서비스 재시작을 넘어 열려 있던 세션은, 서비스가 죽어 있는 동안 잔액을 깎을 수 없었어요. 시작할 때 지나간 시간을 (상한을 두고) 정산하고 새 세션을 시작해요. 그래야 서비스가 보지 못한 시간 때문에 누가 시간을 빼앗기는 일이 없어요.
- Android 14 이상에서는 포그라운드 서비스 알림을 밀어서 지울 수 있어요. 서비스가 알림을 계속 업데이트하니까(매초 다시 올리지 않도록 중복은 걸러요) 알림이 다시 돌아와요.
트레이드오프
이 방식은 접근성보다 느슨해요. 이 점은 솔직하게 말해 두고 싶어요.
- 오버레이가 뜨기 전까지, 차단한 앱이 폴링 한 번 간격(약 1초)까지 보여요.
- 차단한 앱은 오버레이 밑에서 계속 돌아가요. 영상이 재생 중이었다면 ‘홈으로 가기’를 누를 때까지 소리가 계속 들려요.
- 제조사가 서비스를 가만히 둬야 해요. 배터리 설정이 그렇게 중요한 이유예요.
양심 시스템 차단 앱이라면 이게 맞는 거래예요. HonorFit은 절대 뚫리지 않는 걸 목표로 하지 않아요. 작정하고 속이려는 사람은 하지도 않은 운동을 기록하면 그만이에요. 핵심은 잠깐 멈추게 하고 대가를 치르게 하는 거지, 감옥에 가두는 게 아니에요. 그 대신 ‘전체 제어’가 아니라 ‘사용 기록 액세스’라고 적힌 권한 화면, 훨씬 단순한 Play 심사, 그리고 앞에 있는 앱의 패키지 이름이라는 딱 한 가지만 읽는 서비스를 얻어요.
iOS도 만들고 있다면, 거기는 완전히 다른 세상이에요. Apple의 스크린 타임 문서가 알려 주지 않는 것들을 정리해 뒀어요.
HonorFit Blocker는 iPhone과 Android용 무료 앱 차단 앱이에요. 푸시업, 스쿼트, 직접 고른 아무 운동으로나 스크린 타임을 벌어요. 양심 시스템, 카메라 없음, 계정 없음, 모든 데이터는 내 폰 안에만 있어요.