접근성 서비스 만들기

접근성 서비스 는 장애가 있는 사용자 또는 일시적으로 기기와 완벽하게 상호작용할 수 없는 사용자를 지원하기 위해 사용자 인터페이스를 향상하는 앱입니다. 이러한 서비스는 백그라운드에서 실행되며 시스템과 통신하여 화면 콘텐츠를 검사하고 사용자를 대신하여 앱과 상호작용합니다. 예로는 스크린 리더 (예: TalkBack), 스위치 제어 도구, 음성 제어 시스템이 있습니다.

이 가이드에서는 Android 접근성 서비스 빌드의 기본사항을 다룹니다.

접근성 서비스 수명 주기

접근성 서비스를 만들려면 AccessibilityService 클래스를 확장하고 앱의 매니페스트에서 서비스를 선언해야 합니다.

서비스 클래스 만들기

AccessibilityService를 확장하는 클래스를 만듭니다. 다음 메서드를 재정의해야 합니다.

  • onAccessibilityEvent: 시스템에서 서비스의 구성과 일치하는 이벤트 (예: 포커스 변경 또는 버튼 클릭)를 감지할 때 호출됩니다. 여기에서 서비스가 사용자 인터페이스를 해석합니다.
  • onInterrupt: 시스템에서 서비스의 피드백을 중단할 때 호출됩니다 (예: 사용자가 포커스를 빠르게 이동할 때 음성 출력을 중지하기 위해).
package com.example.android.apis.accessibility

import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.AccessibilityServiceInfo
import android.accessibilityservice.FingerprintGestureController
import android.accessibilityservice.AccessibilityButtonController
import android.accessibilityservice.GestureDescription
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.graphics.Path
import android.os.Build
import android.media.AudioManager
import android.content.Context

class MyAccessibilityService : AccessibilityService() {

    override fun onAccessibilityEvent(event: AccessibilityEvent) {
        // Interpret the event and provide feedback to the user
    }

    override fun onInterrupt() {
        // Interrupt any ongoing feedback
    }

    override fun onServiceConnected() {
        // Perform initialization here
    }
}

매니페스트에서 선언

AndroidManifest.xml 파일에서 서비스를 등록합니다. 시스템만 서비스에 결합될 수 있도록 권한을 엄격하게 적용해야 합니다.BIND_ACCESSIBILITY_SERVICE

설정 버튼이 작동하도록 하려면 ServiceSettingsActivity를 선언하세요.

<application>
  <service android:name=".accessibility.MyAccessibilityService"
      android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
      android:exported="true"
      android:label="@string/accessibility_service_label">
      <intent-filter>
          <action android:name="android.accessibilityservice.AccessibilityService" />
      </intent-filter>
      <meta-data
          android:name="android.accessibilityservice"
          android:resource="@xml/accessibility_service_config" />
  </service>

  <activity android:name=".accessibility.ServiceSettingsActivity"
      android:exported="true"
      android:label="@string/accessibility_service_settings_label" />
</application>

서비스 구성

res/xml/accessibility_service_config.xml에서 구성 파일을 만듭니다. 이 파일은 서비스에서 처리하는 이벤트와 제공하는 피드백을 정의합니다. 매니페스트에서 선언한 ServiceSettingsActivity를 참조해야 합니다.

<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/accessibility_service_description"
    android:accessibilityEventTypes="typeAllMask"
    android:accessibilityFlags="flagDefault|flagRequestFingerprintGestures|flagRequestAccessibilityButton"
    android:accessibilityFeedbackType="feedbackSpoken"
    android:notificationTimeout="100"
    android:canRetrieveWindowContent="true"
    android:canPerformGestures="true"
    android:settingsActivity="com.example.android.apis.accessibility.ServiceSettingsActivity" />

구성 파일에는 다음과 같은 주요 속성이 포함됩니다.

  • android:accessibilityEventTypes: 수신하려는 이벤트입니다. 범용 서비스에는 typeAllMask를 사용합니다.
  • android:canRetrieveWindowContent: 서비스에서 UI 계층 구조를 검사해야 하는 경우 (예: 화면에서 텍스트를 읽기 위해) true여야 합니다.
  • android:canPerformGestures: 동작 (예: 스와이프 또는 탭)을 프로그래매틱 방식으로 디스패치하려는 경우 true여야 합니다.
  • android:accessibilityFlags: 플래그를 결합하여 기능을 사용 설정합니다. 지문 동작에는 flagRequestFingerprintGestures가 필요합니다. 소프트웨어 접근성 버튼에는 flagRequestAccessibilityButton이 필요합니다.

구성 옵션의 전체 목록은 AccessibilityServiceInfo를 참고하세요.

런타임 구성

XML 구성은 정적이지만 런타임 시 서비스 구성을 동적으로 수정할 수도 있습니다. 이는 사용자 환경설정에 따라 기능을 전환하는 데 유용합니다.

onServiceConnected()를 재정의하여 setServiceInfo()를 사용하여 런타임 업데이트를 적용합니다.

override fun onServiceConnected() {
    val info = AccessibilityServiceInfo()

    // Set the type of events that this service wants to listen to.
    info.eventTypes = AccessibilityEvent.TYPE_VIEW_CLICKED or AccessibilityEvent.TYPE_VIEW_FOCUSED

    // Set the type of feedback your service provides.
    info.feedbackType = AccessibilityServiceInfo.FEEDBACK_SPOKEN

    // Set flags at runtime.
    info.flags = AccessibilityServiceInfo.FLAG_DEFAULT or
            AccessibilityServiceInfo.FLAG_REQUEST_FINGERPRINT_GESTURES

    this.setServiceInfo(info)
}

UI 콘텐츠 해석

onAccessibilityEvent()가 트리거되면 시스템에서 AccessibilityEvent를 제공합니다. 이 이벤트는 화면 콘텐츠의 계층적 표현인 접근성 트리의 진입점 역할을 합니다.

서비스는 주로 AccessibilityNodeInfo 객체와 상호작용합니다. 이 객체는 버튼, 목록, 텍스트와 같은 UI 요소를 나타냅니다. 이러한 UI 요소에 관한 데이터는 AccessibilityNodeInfo로 정규화됩니다.

다음 예는 이벤트의 소스를 가져오고 접근성 트리를 순회하여 정보를 찾는 방법을 보여줍니다.

override fun onAccessibilityEvent(event: AccessibilityEvent) {
    // Get the source node of the event
    val sourceNode: AccessibilityNodeInfo? = event.source

    if (sourceNode == null) return

    // Inspect properties
    if (sourceNode.isCheckable) {
        val state = if (sourceNode.isChecked) "Checked" else "Unchecked"
        val label = sourceNode.text ?: sourceNode.contentDescription
        
        // Provide feedback (for example, speak to the user)
        speakToUser("$label is $state")
    }

    // Always recycle nodes to prevent memory leaks
    sourceNode.recycle()
}

private fun speakToUser(text: String) {
    // Your text-to-speech implementation goes here
}

사용자 대신 작업

접근성 서비스는 사용자를 대신하여 버튼 클릭 또는 목록 스크롤과 같은 작업을 실행할 수 있습니다.

작업을 실행하려면 AccessibilityNodeInfo 객체에서 performAction()을 호출합니다.

fun performClick(node: AccessibilityNodeInfo) {
    if (node.isClickable) {
        node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
    }
}

뒤로 버튼 누르기 또는 알림 창 열기와 같이 전체 시스템에 영향을 미치는 전역 작업에는 performGlobalAction()을 사용합니다.

// Navigate back
fun navigateBack() {
    performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK)
}

포커스 관리

Android에는 두 가지 고유한 포커스 유형이 있습니다. 입력 포커스 (키보드 입력이 이동하는 위치)와 접근성 포커스 (접근성 서비스에서 검사하는 항목)입니다.

다음 스니펫은 현재 접근성 포커스가 있는 요소를 찾는 방법을 보여줍니다.

// Find the node that currently has accessibility focus
// Note: rootInActiveWindow can be null if the window is not available
val root = rootInActiveWindow
if (root != null) {
    val focusedNode = root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)

    // Do something with focusedNode

    // Always recycle nodes
    focusedNode?.recycle()
    // rootInActiveWindow doesn't need to be recycled, but obtained nodes do.
}

다음 스니펫은 접근성 포커스를 특정 요소로 이동하는 방법을 보여줍니다.

// Request that the system give focus to a given node
fun focusNode(node: AccessibilityNodeInfo) {
    node.performAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS)
}

접근성 서비스를 만들 때는 사용자 포커스 상태를 존중하고 사용자 작업에 의해 명시적으로 트리거되지 않는 한 포커스를 가져오지 마세요.

동작 실행

서비스는 스와이프, 탭 또는 멀티 터치 상호작용과 같은 맞춤 동작을 화면에 디스패치할 수 있습니다. 이렇게 하려면 구성에서 android:canPerformGestures="true"를 선언하여 dispatchGesture() API를 사용할 수 있도록 합니다.

간단한 동작

간단한 동작을 실행하려면 먼저 Path 객체를 만들어 지정된 동작과 관련된 이동을 나타냅니다. 그런 다음 PathGestureDescription으로 래핑하여 스트로크를 설명합니다. 마지막으로 dispatchGesture를 호출하여 동작을 디스패치합니다.

fun swipeRight() {
    // Create a path for the swipe (from x=100 to x=500)
    val swipePath = Path()
    swipePath.moveTo(100f, 500f)
    swipePath.lineTo(500f, 500f)

    // Build the stroke description (0ms delay, 500ms duration)
    val stroke = GestureDescription.StrokeDescription(swipePath, 0, 500)

    // Build the gesture description
    val gestureBuilder = GestureDescription.Builder()
    gestureBuilder.addStroke(stroke)

    // Dispatch the gesture
    dispatchGesture(gestureBuilder.build(), object : AccessibilityService.GestureResultCallback() {
        override fun onCompleted(gestureDescription: GestureDescription?) {
            super.onCompleted(gestureDescription)
            // Gesture finished successfully
        }
    }, null)
}

이어지는 동작

L자 모양 그리기 또는 정확한 다단계 드래그 실행과 같은 복잡한 상호작용의 경우 willContinue 매개변수를 사용하여 스트로크를 함께 연결할 수 있습니다.

fun performLShapedGesture() {
    val path1 = Path().apply {
        moveTo(200f, 200f)
        lineTo(400f, 200f)
    }

    val path2 = Path().apply {
        moveTo(400f, 200f)
        lineTo(400f, 400f)
    }

    // First stroke: willContinue = true
    val stroke1 = GestureDescription.StrokeDescription(path1, 0, 500, true)

    // Second stroke: continues immediately after stroke1
    val stroke2 = stroke1.continueStroke(path2, 0, 500, false)

    val builder = GestureDescription.Builder()
    builder.addStroke(stroke1)
    builder.addStroke(stroke2)

    dispatchGesture(builder.build(), null, null)
}

오디오 관리

접근성 서비스 (특히 스크린 리더)를 만들 때는 STREAM_ACCESSIBILITY 오디오 스트림을 사용하세요. 이렇게 하면 사용자가 시스템 미디어 볼륨과 별도로 서비스 볼륨을 제어할 수 있습니다.

fun increaseAccessibilityVolume() {
    val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
    audioManager.adjustStreamVolume(
        AudioManager.STREAM_ACCESSIBILITY,
        AudioManager.ADJUST_RAISE,
        0
    )
}

XML 또는 런타임 시 setServiceInfo를 통해 구성에 FLAG_ENABLE_ACCESSIBILITY_VOLUME 플래그를 포함해야 합니다.

고급 기능

지문 동작

Android 10 (API 수준 29) 이상을 실행하는 기기에서 서비스는 지문 센서에서 방향 스와이프를 캡처할 수 있습니다. 이는 대체 탐색 컨트롤을 제공하는 데 유용합니다.

onServiceConnected() 메서드에 다음 로직을 추가합니다.

// Import: android.os.Build
// Import: android.accessibilityservice.FingerprintGestureController

private var gestureController: FingerprintGestureController? = null

override fun onServiceConnected() {
    // Check if the device is running Android 10 (Q) or higher
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        gestureController = fingerprintGestureController

        val callback = object : FingerprintGestureController.FingerprintGestureCallback() {
            override fun onGestureDetected(gesture: Int) {
                when (gesture) {
                    FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_DOWN -> {
                        // Handle swipe down
                    }
                    FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_UP -> {
                        // Handle swipe up
                    }
                }
            }
        }

        gestureController?.registerFingerprintGestureCallback(callback, null)
    }
}

접근성 버튼

소프트웨어 탐색 키를 사용하는 기기에서 사용자는 탐색 메뉴의 접근성 버튼을 통해 서비스를 호출할 수 있습니다.

이 기능을 사용하려면 서비스 구성에 FLAG_REQUEST_ACCESSIBILITY_BUTTON 플래그를 추가합니다. 그런 다음 등록 로직을 onServiceConnected() 메서드에 추가합니다.

// Import: android.accessibilityservice.AccessibilityButtonController

override fun onServiceConnected() {
    // ... existing initialization code ...

    val controller = accessibilityButtonController

    controller.registerAccessibilityButtonCallback(
        object : AccessibilityButtonController.AccessibilityButtonCallback() {
            override fun onClicked(controller: AccessibilityButtonController) {
                // Respond to button tap
            }
        }
    )
}

다국어 텍스트 음성 변환

텍스트를 소리 내어 읽는 서비스는 소스 텍스트가 LocaleSpan으로 태그 지정된 경우 언어를 자동으로 전환할 수 있습니다. 이렇게 하면 서비스에서 수동 전환 없이 혼합 언어 콘텐츠를 올바르게 발음할 수 있습니다.

import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.LocaleSpan
import java.util.Locale

// Wrap text in LocaleSpan to indicate language
val spannable = SpannableStringBuilder("Bonjour")
spannable.setSpan(
    LocaleSpan(Locale.FRANCE),
    0,
    spannable.length,
    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)

서비스에서 AccessibilityNodeInfo를 처리할 때 text 속성에서 LocaleSpan 객체를 검사하여 올바른 텍스트 음성 변환 언어를 결정합니다.

추가 리소스

자세한 내용은 다음 리소스를 참고하세요.

가이드

Codelab

뷰 콘텐츠