본문 바로가기
프로젝트/AI 작업 모니터

Android 접근성 서비스로 AI 휴대폰 제어, 바뀐 버튼을 누른 이유

by iwbap 2026. 10. 30.
728x90
반응형

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

어두운 배경에 AI가 휴대폰 화면을 보고 누르게 했더니 바뀐 버튼을 눌렀다는 표지 문구와 화면을 보지 않은 조작은 거부한다는 부제, 조작 직전 접근성 트리를 다시 읽어 관측 때와 비교하고 성공한 조작이 15분 동안 없으면 자동 종료한다는 설명이 있고, 오른쪽에는 원래 버튼 관측, 같은 창에서 버튼만 바뀜, 점선 빨간 테두리의 예전 관측 ID로 터치 요청, 다시 읽고 비교해 다르면 거절로 이어지는 휴대폰 모양 흐름이 있는 대표 이미지
화면을 보지 않은 조작은 거부한다

AI는 화면을 본 뒤에만 누를 수 있다

AI는 로컬 MCP 어댑터와 기존 HTTPS 서버, 휴대폰 활성 세션을 거쳐 AccessibilityService에 연결된다. 사용에 USB·같은 Wi-Fi·PC ADB는 필요 없다. 휴대폰에서 접근성을 승인하고 제어를 시작해야 한다.

  • 화면 조회(observe): 축소한 JPEG와 텍스트·버튼·입력칸 목록
  • 요소 조작(click, set_text): 화면 요소 ID로 클릭·입력
  • 좌표 조작(tap, swipe)과 뒤로가기·홈

규칙은 하나다. 조작할 때마다 가장 최근 관측 ID를 함께 보내야 한다. 관측 ID는 한 번만 쓸 수 있고, 30초가 지났거나 창·회전이 달라지면 거절한다.

AI 휴대폰 제어의 연결 순서와 관측 규칙을 나타낸 도식, PC의 AI인 Cursor와 Codex에서 로컬 MCP 어댑터와 기존 HTTPS 서버를 거쳐 휴대폰 활성 세션과 Android 접근성 서비스로 이어지며 USB나 같은 Wi-Fi나 PC의 ADB 없이 동작하고 사용자가 휴대폰에서 접근성을 켜고 제어를 시작해야 세션이 생기며, 화면 조회는 긴 변 최대 1280px JPEG와 텍스트·버튼·입력칸 목록을 돌려주고 조작마다 가장 최근 관측 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")}

오래된 화면은 조작 직전에 접근성 트리를 다시 읽어 막았다. 관측 때 저장한 요소의 텍스트·설명·위치·상태·구조와 비교해, 하나라도 다르면 다시 보라고 거절한다.

같은 창에서 버튼이 바뀌었을 때의 수정 전후 흐름도, 수정 전에는 원래 버튼을 관측한 뒤 같은 창 안에서 문구와 동작이 바뀌어도 예전 관측 ID로 보낸 터치가 거절되지 않아 바뀐 버튼이 눌리고 의도하지 않은 클릭이 1번 생겼으며 검사는 관측 ID·30초·창·패키지·회전까지만 봤고, 수정 후에는 관측 때 화면 상태를 저장하고 조작 직전 접근성 트리를 다시 읽어 텍스트·설명·위치·상태·구조를 비교해 다르면 거절하며 Android 13 이상은 캐시를 지우고 11~12는 노드를 새로 읽고 캡처 중 바뀌면 화면 조회만 최대 3번 다시 할 뿐 조작은 반복하지 않는다는 설명

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초 유예 안에서 끝낼 수 있다. 유예 중 새 명령은 받지 않는다. 앱 쪽 타이머는 단조 시계를 쓰고, 늦게 온 결과가 끝난 세션을 되살리지 못하게 했다.

15분 비활동 자동 종료 타임라인, 제어를 시작하면 만료가 15분 뒤로 잡히고 성공한 화면 조회나 조작이 접수될 때마다 만료를 15분 뒤로 미루며 만료 직전에 받은 명령은 20초 유예 안에서 마치고 15분 동안 활동이 없으면 자동 종료되며, 세션 목록·명령 상태 조회와 10초 연결 유지 신호와 재연결과 실패·중복·시간 초과 결과는 연장하지 않고, 유예 중 새 명령은 받지 않으며 늦게 온 결과는 단조 시계 기준으로 끝난 세션을 되살리지 못하고 자동으로 다시 켜지지 않는다는 설명

@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를 참고했다.

728x90
반응형