AI 작업 모니터 앱에 PC의 AI가 휴대폰 화면을 보고 누르는 기능을 넣었다. Android 접근성 서비스로 화면 요소를 읽고 터치를 보낸다. 감사 중 바뀐 버튼을 누르는 문제를 찾아 수정 과정과 15분 종료 규칙을 정리했다.

AI는 화면을 본 뒤에만 누를 수 있다
AI는 로컬 MCP 어댑터와 기존 HTTPS 서버, 휴대폰 활성 세션을 거쳐 AccessibilityService에 연결된다. 사용에 USB·같은 Wi-Fi·PC ADB는 필요 없다. 휴대폰에서 접근성을 승인하고 제어를 시작해야 한다.
- 화면 조회(observe): 축소한 JPEG와 텍스트·버튼·입력칸 목록
- 요소 조작(click, set_text): 화면 요소 ID로 클릭·입력
- 좌표 조작(tap, swipe)과 뒤로가기·홈
규칙은 하나다. 조작할 때마다 가장 최근 관측 ID를 함께 보내야 한다. 관측 ID는 한 번만 쓸 수 있고, 30초가 지났거나 창·회전이 달라지면 거절한다.

화면 영상을 상시 전송하지 않는다. JPEG는 요청이나 조작 결과에만 만들며 긴 변은 최대 1280px이다.
꺼진 것처럼 보이는데 화면이 읽혔다
기능을 붙인 뒤 버그 감사를 했다. 실행 중 문제 2건과 Lint 검사 실패 1건을 찾았다. 수정 전 에뮬레이터에서 재현했다.
첫째는 세션 상태였다. 첫 생성 응답을 지연한 뒤 제어를 껐다 다시 켜자, 늦은 응답이 새 서비스 상태를 덮어 꺼짐으로 보이는데도 새 세션의 화면 조회가 캡처를 돌려줬다. 동기 HTTP 요청은 코루틴 취소로 멈추지 않았고 응답 뒤 소유권 확인도 없었다.
둘째는 오래된 화면이었다. 자체 테스트 화면에서 버튼 문구·동작을 같은 창에서 바꿨지만, 이전 관측 ID로 보낸 tap과 click이 바뀐 버튼을 눌렀다. 당시 검사는 관측 ID·30초·창·패키지·회전까지만 확인했다.
// … 수정 전: 화면 변경 이벤트는 시각만 기록했다
override fun onAccessibilityEvent(event:AccessibilityEvent?){lastEvent=SystemClock.elapsedRealtime()}
// … 요소 클릭도 표시·활성·비밀번호·창만 확인했다
check(node.refresh()&&node.isVisibleToUser&&node.isEnabled&&!node.isPassword&&node.windowId==observedWindow)
세 번째는 최소 API 29 앱에서 API 30 함수를 써 Lint NewApi 오류 10개가 난 것이다. 최소 SDK는 유지하고 API 요구사항을 명시했다.
서비스 소유권과 화면 비교로 고쳤다
Android 2.4.5에서 세 건을 고쳤다. 세션 생성·결과 전송 HTTP를 취소 가능하게 바꾸고 SSE도 추적했다. 현재 서비스만 상태를 바꾸며 늦은 생성 응답의 세션만 종료한다.
private fun publish(update:(PhoneControlState)->PhoneControlState) = synchronized(stateLock) {
if(!stopping&&owner===this)mutable.value=update(mutable.value)
}
// … 세션 생성 응답을 받은 뒤 현재 서비스가 맞는지 확인한다
val adopted=synchronized(stateLock){
if(stopping||owner!==this@PhoneControlService||!scope.isActive)false else {relay=session;true}
}
if(!adopted){stopRemote(session);throw CancellationException("Obsolete phone control session")}
오래된 화면은 조작 직전에 접근성 트리를 다시 읽어 막았다. 관측 때 저장한 요소의 텍스트·설명·위치·상태·구조와 비교해, 하나라도 다르면 다시 보라고 거절한다.

val previousId=observed
val previousScreen=observedScreen
observed="";observedScreen=null
// … 관측 ID, 30초 제한 확인 뒤 트리를 다시 읽는다
val current=readSnapshot()
check(current.state==previousScreen){"화면 내용이 바뀌었습니다. 먼저 화면을 다시 확인하세요"}
Android 13 이상에서는 접근성 캐시를 지우고, 11~12에서는 노드를 새로 읽는다. 캡처 전후가 다르면 500ms 간격으로 화면만 최대 세 번 다시 조회한다. 조작은 재실행하지 않는다.
비교는 접근성 트리에서 읽을 수 있는 범위에 한한다. 검사 직후 화면이 바뀌는 경쟁까지 원자적으로 막지는 못한다.
15분 동안 조작이 없으면 끈다
처음에는 시작 시점부터 고정 15분이었다. 이를 마지막 성공 뒤 15분으로 바꿔, 성공한 화면 조회·조작 결과를 서버가 접수할 때만 연장한다.
- 연장: 성공한 observe, tap, swipe, click, set_text, back, home
- 연장 안 함: 세션 목록·명령 상태 조회, 10초 연결 유지 신호, 재연결
- 연장 안 함: 실패·중복·시간 초과 결과
만료 직전에 받은 명령은 20초 유예 안에서 끝낼 수 있다. 유예 중 새 명령은 받지 않는다. 앱 쪽 타이머는 단조 시계를 쓰고, 늦게 온 결과가 끝난 세션을 되살리지 못하게 했다.

@Synchronized fun beginCommand(): Boolean {
val time = now()
if (time >= deadline || commandDeadline != 0L) return false
commandDeadline = time + COMMAND_GRACE_MILLIS
return true
}
@Synchronized fun finishCommand(successful: Boolean) {
// A late result must not resurrect a timed out local session.
if (successful && commandDeadline != 0L && now() < commandDeadline) {
deadline = now() + IDLE_MILLIS
}
commandDeadline = 0L
}
어떻게 검증했나
수정 전에는 tap·click·set_text 허용과 늦은 생성 응답 문제를 재현했다. 수정 뒤 신규 회귀 5개와 기존 제어 3개, 단위 131개, 관련 UI 13개가 통과했고 Lint 오류는 0개였다.
실기기에서는 화면 조회·클릭·입력·터치·스와이프·뒤로·홈을 확인했다. 같은 창의 초안을 바꾼 뒤 이전 관측의 set_text·tap·click이 모두 거절됐고, 11초간 연결 신호·목록 조회만으로 만료는 연장되지 않았다. 실제 15분 대기와 Android 11~12 실행, 지연 응답의 실기기 재현은 하지 않았다.
FAQ
Q. 접근성 설정에 '앱의 액세스가 거부됨'이 나오면?
A. 일부 외부 설치 앱에서 나타나는 Android 제한이다. 앱 정보의 ⋮에서 ‘제한된 설정 허용’을 사용자가 직접 확인한 뒤 접근성을 켠다. 앱이 권한을 자동으로 켜지 않는다.
이번에 배운 것
- 화면을 보지 않은 조작은 거부하는 편이 안전하다.
- 취소한 작업의 늦은 응답은 현재 상태를 건드리면 안 된다.
- 세션 연장은 성공한 실제 조작에만 묶어야 한다.
앞 글은 Compose 역순 채팅 목록, 펼친 도구 제목이 -10,619dp로 밀린 이유다. 화면 캡처와 제스처는 Android 공식 문서 AccessibilityService를 참고했다.
'프로젝트 > AI 작업 모니터' 카테고리의 다른 글
| Compose 역순 채팅 목록, 펼친 도구 제목이 -10,619dp로 밀린 이유 (0) | 2026.10.30 |
|---|---|
| Cursor 첫 답변이 작업 완료 뒤 맨 아래에 또 나온 이유, 사라진 임시 답변 (0) | 2026.10.30 |
| Cursor 작업이 끝났는데 도구 카드 3개가 계속 돈 이유, 두 개의 호출 ID (0) | 2026.10.30 |
| 백그라운드 SSE 1.2MB를 65KB로, 안 보이는 화면은 조회를 멈췄다 (0) | 2026.10.29 |
| 사용 한도 자동 재개 예약, 429만으로는 한도라고 판단하지 않았다 (0) | 2026.10.29 |