앱 기본사항

원활한 교차 기기 상호작용 지원

스피커

정보

강연 소개

사용자가 휴대전화, 웨어러블, 오디오 주변기기를 오가며 사용함에 따라 원활한 교차 기기 환경을 제공하는 것이 참여도에 필수적인 요소가 되었습니다. 하지만 표준 네트워킹 접근 방식에 의존하면 배터리 소모, 지연 시간 급증, 사용자 세션 단편화가 발생하는 경우가 많습니다.

이 심층 분석에서는 멀티 디바이스 통신을 위해 빌드된 특수 Android API를 소개하고, 기기 간 전환을 즉각적이고 안정적이며 직관적으로 유지하는 메모리 및 전력 효율적인 패턴을 제공합니다.

요약

  • 호환 기기 관리자는 현재 상태에 따라 앱을 절전 모드 해제하고 배터리를 소모하지 않고 연결을 관리하여 블루투스 주변기기의 포그라운드 서비스를 대체합니다. Android 17에는 의료 기기 및 피트니스 트래커 전용의 새로운 프로필이 추가되었습니다.
  • Jetpack Telecom은 Android 8까지 거슬러 올라가 시계, 자동차와 같은 연결된 기기에서 VoIP 앱의 시스템 통화 조정자 역할을 합니다. 또한 CallControlScope를 통해 음소거 및 끊기 작업을 동기화합니다.
  • 출시 예정인 Jetpack Telecom 1.1.0 기능에는 시스템 다이얼러의 통합 통화 기록 (SDK 36.1 이상)과 지원되는 이동통신사 네트워크의 자동 5G 네트워크 슬라이싱이 포함됩니다.
  • 내년에 Quick Share SDK가 출시되어 개발자가 로컬 엔드 투 엔드 암호화 파일 공유를 Android 및 iOS 앱에 직접 삽입할 수 있게 됩니다. 탭하여 공유 (Pixel 11에서 출시)는 닫혀 있는 수신 앱을 자동으로 절전 모드 해제할 수도 있습니다.

리소스

Gemini Notebook

주제

  • 교차 기기
  • companion-device-manager
  • jetpack-telecom
  • quick-share
  • 블루투스
  • voip
  • P2P

스크립트

교차 기기 상호작용 소개

  • 안녕하세요.

  • '원활한 교차 기기 상호작용 지원'에 오신 것을 환영합니다.

  • 저는 한루이입니다. 오늘은 제 동료인 야신과 나타라즈가 함께합니다.

  • 연결에 관해 이야기할 때 흔히 떠오르는 것은 기본적인 네트워크 링크나 블루투스 헤드셋입니다.

  • Android에서는 연결성이 이보다 훨씬 광범위합니다.

  • 기기가 더 원활하게 연동되도록 하는 것이 핵심입니다.

  • 오늘은 일반적인 교차 기기 시나리오를 살펴보고 각 시나리오에 적합한 API를 보여드리겠습니다.

  • 오랫동안 우리는 휴대전화라는 단일 기기용 앱을 빌드했습니다.

  • 그 후 웨어러블이 인기를 끌기 시작했습니다.

  • 곧 스마트시계를 휴대전화와 페어링하여 백그라운드에서 데이터를 동기화하거나 직접 전화를 받는 것이 매우 일반적인 일이 되었습니다.

  • 무선 이어폰을 추가해 줘.

  • 이제 사용자는 통화 중에 워치에서 휴대전화, 이어폰, 자동차로 오디오 기기를 전환합니다.

  • 최근에는 많은 사용자가 폴더블이나 태블릿과 같은 두 번째 대형 화면 기기도 휴대합니다.

  • 사용자는 앱이 이러한 기기에서 원활하게 작동하기를 기대합니다.

  • 이러한 기능은 노트북과 Chromebook으로도 확장되어 사용자가 책상에서 바로 Android 앱을 실행하고 연결 상태를 유지할 수 있습니다.

  • 2023년에도 평균적으로 사용자는 3~4대의 기기를 소유하고 있었습니다.

  • 사용자는 이러한 기기에서 독립형 기능 이상의 것을 기대합니다.

  • 이들은 뛰어난 연동 환경을 원합니다.

  • 이러한 '함께하면 더 좋은' 경험은 오늘날 우리와 같이 같은 공간에 있는 사람들이 옆에 있는 사람에게 사진, 링크 또는 앱을 쉽게 공유하고 싶을 때 특히 분명하게 드러납니다.

  • 수치를 보면 생태계가 거대하고 빠르게 성장하고 있습니다.

  • 올해 전 세계 IoT 기기는 약 220억 대로 전년 대비 14% 증가했습니다.

  • 이러한 모든 엔드포인트 중 Wi-Fi가 1/3을 차지하고 블루투스가 1/4을 차지합니다.

  • 교차 기기 연결은 더 이상 예외적인 사용 사례가 아닙니다.

  • 일상생활의 일부가 되었습니다.

  • 하지만 오늘날 대부분의 앱은 여전히 하위 수준 네트워크 및 블루투스 API를 기반으로 교차 기기 기능을 빌드하므로 세 가지 문제가 발생합니다.

  • 첫째, 배터리 및 메모리입니다.

  • 백그라운드 스캔 및 서비스를 계속 실행하면 배터리와 메모리가 소모됩니다.

  • 두 번째는 지연 시간입니다.

  • 예측할 수 없는 지연으로 인해 실시간 통화 및 데이터 동기화가 중단됩니다.

  • 셋째, 연결 마찰입니다.

  • 모든 기기에서 검색, 페어링, 재연결을 관리하는 것은 복잡하며 사용자 환경이 쉽게 손상될 수 있습니다.

  • 이러한 문제를 해결하기 위해 Google은 세 가지 주요 영역에 걸쳐 목적에 맞는 API를 구축하여 교차 기기 기능을 매우 간단하게 만들었습니다.

  • 먼저 호환 앱에 대해 알아본 다음, 나타라지가 VoIP 앱을 다루고, 마지막으로 야신이 P2P 공유에 대해 설명하겠습니다.

호환 앱 개요

  • 먼저 호환 앱을 살펴보겠습니다.

  • 호환 앱은 스마트시계나 혈당 센서와 같은 컴패니언 기기가 휴대전화와 원활하게 작동하도록 휴대전화에 설치하는 앱입니다.

  • 이 섹션에서는 블루투스로 연결된 기기에 중점을 둡니다.

  • 컴패니언 기기는 일반적으로 네 가지 카테고리로 분류됩니다.

  • 첫째, 혈당 센서 및 활력 징후 모니터와 같은 의료 기기입니다.

  • 스마트워치, 피트니스 밴드와 같은 웨어러블 기기

  • 빠른 페어링 및 스마트 컨트롤이 지원되는 헤드셋

  • 디지털 자동차 키, 핸즈프리 통화와 같은 자동차 키트

  • 각 카테고리에는 고유한 주요 사용자 여정이 있지만, 모든 카테고리는 기기와 휴대전화 간의 안정적인 연결에 의존합니다.

  • 의료 기기의 경우 이러한 연결은 생명에 지대한 영향을 미칠 수도 있습니다.

  • 이러한 카테고리에서 공통적으로 나타나는 네 가지 요구사항을 살펴보겠습니다.

  • 첫 번째는 기기 관리입니다.

  • 앱은 이어폰이나 스피커가 아닌 시계와 같이 주변에 있는 모든 기기 중에서 올바른 기기를 찾은 다음 페어링 흐름을 시작하여 연결해야 합니다.

  • 둘째, 인기척 감지입니다.

  • 앱은 기기가 근처에 있을 때와 없을 때를 알아야 합니다.

  • 기기가 나타나면 사용자가 앱을 열지 않아도 앱이 작업을 수행할 수 있도록 앱을 깨울 방법이 필요합니다.

  • 세 번째는 연결 유지입니다.

  • 연결되면 앱은 연결이 안정적이고 지속적이기를 원합니다.

  • 사용자가 범위를 벗어났다가 다시 돌아오는 등 연결이 끊어지면 앱이 실행 중이 아니더라도 자동으로 다시 연결되어야 합니다.

  • 넷째, 데이터 동기화 및 전송입니다.

  • 이것이 바로 진정한 가치입니다.

  • 센서 판독값 동기화, 펌웨어 업데이트 푸시, 기기에 설정 전송

  • 의료 기기의 경우 이는 매우 중요할 수 있습니다.

  • 수치를 놓치면 알림을 놓칠 수 있습니다.

  • 이러한 모든 작업은 앱 프로세스가 활성 상태이고 적절한 시점에 연결되어 있어야 합니다.

  • 그렇다면 앱은 이러한 요구사항을 어떻게 충족했을까요?

  • 기존에는 앱이 포그라운드 서비스에 크게 의존했습니다.

  • 다음은 일반적인 구현입니다.

  • 이전에 호환 앱을 빌드한 적이 있다면 이 과정이 익숙할 수 있습니다.

  • 블루투스 검색 및 연결 권한, 포그라운드 서비스 권한, 연결된 기기의 포그라운드 서비스 유형을 선언한 다음, 포그라운드 서비스는 지속적인 알림을 표시해야 하므로 알림 권한을 선언합니다.

    <uses-permission android:name="android.permission.BLUETOOTH_SCAN"
                     android:usesPermissionFlags="neverForLocation" />
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
  • 그런 다음 고정 서비스를 실행하여 앱 프로세스를 활성 상태로 유지합니다.

    class BleForegroundService : Service() {
       override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
          …
          return START_STICKY
       }
    }
  • 이 방법은 효과가 있지만 기기가 근처에 없을 때도 서비스가 항상 실행되므로 배터리와 메모리가 소모됩니다.

  • 그래서 호환 기기 관리자(CDM)를 만들었습니다.

  • 첫째, 기기 현재 상태에 따라 앱을 절전 모드에서 해제할 수 있으며 기기가 범위 내에 있는 한 시스템은 앱 프로세스를 활성 상태로 유지합니다.

  • 포그라운드 서비스가 필요하지 않습니다.

  • 둘째, CDM은 기기 프로필을 지원합니다.

  • 기기 프로필을 사용하면 사용자가 일련의 권한 대화상자 대신 한 번의 탭으로 앱에 필요한 모든 권한을 부여할 수 있습니다.

  • 이렇게 하면 사용자 환경과 전환율이 크게 개선됩니다.

  • 시계 프로필은 Android 12부터, 안경은 Android 14부터 사용할 수 있습니다.

  • Android 17에서는 의료 기기 및 피트니스 트래커를 위한 두 가지 새로운 프로필이 추가되었습니다.

  • 현재 시스템 앱 전용의 권한이 있는 프로필도 있습니다.

  • 생태계에서 더 많은 수요가 있다면 더 많은 개발자에게 공개하는 것을 고려할 수 있습니다.

호환 기기 관리자를 사용한 구현

  • CDM은 사용하기가 매우 쉽습니다.

  • 의료 기기를 예로 들어 보겠습니다.

  • 먼저 필요한 권한을 선언합니다. 의료 기기용 권한, 백그라운드에서 실행하기 위한 권한, 기기 상태를 관찰하기 위한 권한이 필요합니다.

    <uses-permission android:name="android.permission.REQUEST_COMPANION_PROFILE_MEDICAL" />
    <uses-permission android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND" />
    <uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" />
  • 한 가지 주목할 점은 방금 살펴본 포그라운드 서비스 구현 권한과 달리 여기에 나열된 모든 권한은 일반 권한이므로 대화상자 프롬프트 없이 설치 시 부여된다는 것입니다.

  • 사용자가 승인해야 하는 유일한 항목은 연결입니다.

  • 이 연결을 만드는 방법을 살펴보겠습니다.

  • 여기에서는 올바른 기기만 표시되도록 기기 필터를 사용하여 연결 요청을 빌드합니다.

    val paringRequest = AssociationRequest.Builder()
       .addDeviceFilter(deviceFilter)
       .setSingleDevice(true)
       .setDeviceProfile(AssociationRequest.DEVICE_PROFILE_MEDICAL)
       .build()
    
    companionDeviceManager.associate(paringRequest, ContextCompat.getMainExecutor(this),
       object : CompanionDeviceManager.Callback() {
           override fun onAssociationPending(intentSender: IntentSender) {
               intentSenderLauncher.launch(IntentSenderRequest.Builder(intentSender).build())
           }
    
           override fun onAssociationCreated(associationInfo: AssociationInfo) { … }
    
           override fun onFailure(error: CharSequence?) { … }
       }
    )
  • setSingleDevice를 true로 설정하고 프로필을 의료로 설정합니다.

  • 그런 다음 현장 담당자에게 연락합니다.

  • CDM이 스캔을 처리합니다.

  • onAssociationPending에서 IntentSender를 가져옵니다.

  • 실행하면 시스템에 연결 대화상자가 표시됩니다.

  • 의료 프로필을 사용하면 여기를 한 번 탭하는 것만으로 연결 자체를 승인할 뿐만 아니라 프로필에 필요한 모든 권한을 부여하고, 중요한 알림에 필수적인 방해 금지 모드도 우회할 수 있습니다.

  • 사용자가 '허용'을 탭하면 연결 ID가 포함된 AssociationInfo와 함께 onAssociationCreated가 표시됩니다.

  • 그런 다음 해당 연결 ID로 startObservingDevicePresence를 호출하여 기기 현재 상태를 관찰할 수 있습니다.

  • 또한 CompanionDeviceService를 확장하는 서비스를 구현해야 합니다.

    class BleCompanionDeviceService : CompanionDeviceService() {
    
       override fun onDevicePresenceEvent(event: DevicePresenceEvent) {
           when (event.event) {
               DevicePresenceEvent.EVENT_BLE_APPEARED -> onDeviceAppeared(event.associationId)
               DevicePresenceEvent.EVENT_BLE_DISAPPEARED -> onDeviceDisappeared(event.associationId)
           }
       }
    
       private fun onDeviceAppeared(associationId: Int) { … }
    
       private fun onDeviceDisappeared(associationId: Int) { … }
    }
  • 기기가 범위 내에 들어오면 앱이 실행 중이 아니더라도 시스템이 서비스에 바인딩됩니다.

  • 시스템은 EVENT_BLE_APPEARED를 사용하여 onDevicePresenceEvent를 호출합니다.

  • 그런 다음 연결하고 동기화할 수 있습니다.

  • 기기가 떠나면 EVENT_BLE_DISAPPEARED을(를) 받게 됩니다.

  • 그런 다음 삭제를 수행할 수 있습니다.

  • 상시 사용 설정 서비스가 없고, 활성 상태를 유지하기 위한 알림이 없습니다.

  • CDM에는 개발자가 염두에 두어야 할 몇 가지 제한사항이 있습니다.

  • 먼저 OS 버전입니다.

  • 대부분의 CDM 기능은 Android 12 및 13에서 도입되었습니다.

  • 하지만 Android 16과 Android 17의 새로운 프로필에는 몇 가지 멋진 개선사항이 도입되었습니다.

  • 따라서 런타임에 OS 버전을 확인하고 이전 버전에 대한 대체 기능을 유지하세요.

  • 하지만 타겟 SDK는 매년 증가하고 최소 SDK도 이를 따르는 경향이 있습니다.

  • 앱이 현재 직면하고 있으며 앞으로도 직면하게 될 메모리 부족 문제를 해결하려면 앱을 포그라운드 서비스에서 CDM으로 최대한 빨리 이전하는 것이 좋습니다.

  • 현재 CDM에서 지원할 수 없는 사용 사례가 있다면 알려주세요.

  • 둘째, 현재 CDM은 백그라운드 스캔을 지원하지만 백그라운드 알림은 지원하지 않습니다.

  • 이러한 필요성은 거의 제기되지 않습니다.

  • 하지만 앱에서 휴대전화가 주변기기 역할을 하고 백그라운드에서 지속적으로 알림을 보내야 하는 경우 이에 관해 자세히 알려주시기 바랍니다.

  • 셋째, CDM은 저전력 스캔 모드를 사용합니다.

  • 배터리와 메모리를 위해 실시간 응답성을 약간 희생합니다.

  • 따라서 즉각적인 절전 모드 해제를 기대하지 마세요.

  • 마지막으로 페이로드 엿보기가 없습니다.

  • 특정 특성 또는 광고 데이터에 등록할 수 없습니다.

  • 하지만 기기가 범위 내에 있으면 앱이 절전 모드에서 해제됩니다.

  • 따라서 필요에 따라 연결하고 페이로드를 확인할 수 있습니다.

  • 호환 앱과 CDM에 관한 내용은 여기까지입니다.

  • 이제 나타라지에게 VoIP 앱에 관한 설명을 부탁하겠습니다.

교차 기기 VoIP 통화의 문제점

  • 앱이 전용 주변기기를 소유하고 지속적으로 통신하는 경우 CompanionDeviceManager가 적합한 도구입니다.

  • 안녕하세요.

  • 저는 나타라즈 K R입니다.

  • 이제 다음 교차 기기 시나리오인 실시간 통화에 대해 이야기하겠습니다.

  • 요즘에는 사람들이 전화를 처리하는 방식이 변화하고 있습니다.

  • 통화의 40% 이상이 VoIP 네트워크를 통해 이루어집니다.

  • Android는 이러한 변화에서 중요한 역할을 하고 있습니다.

  • 실제로 이러한 통화의 71% 는 Android 기기에서 이루어지며, 이는 다른 모든 통화와 마찬가지입니다.

  • 통화만 그런 것이 아닙니다.

  • 사람들이 소유하는 기기 생태계도 엄청나게 성장하고 있습니다.

  • 현재 2억 5천만 대 이상의 차량에서 Android Auto가 실행되고 있으며, 10억 대 이상의 블루투스 기기가 사용자에게 제공되고 있습니다.

  • 이어폰, Wear OS 시계, Android, Android Auto가 설치된 자동차, 블루투스 스피커는 모두 사용자가 휴대전화와 함께 소유하고 있는 기기이며, 사용자는 이러한 모든 기기에서 통화가 원활하게 이루어지기를 원합니다.

  • 예를 들어 사용자는 전화를 받으면 시계를 보고 발신자 정보를 확인합니다.

  • 워치에서 전화를 받습니다.

  • 필요에 따라 음소거하거나 음소거를 해제하고, 이어폰을 끼고 통화를 계속할 수 있습니다.

  • 외출하고 싶다면 차에 타서 통화가 차량 내부로 원활하게 전환되기를 기대합니다.

  • 이것이 사용자에게 일상이 되었으며, 프리미엄 경험이 아닙니다.

  • 사용자에게 이는 기본적인 요구사항이며, VoIP 앱에서 사용자에게 원활한 환경을 제공하는 것은 Android 개발자의 책임입니다.

  • 하지만 이 모든 것을 처음부터 직접 빌드하려고 하면 세 가지 주요 문제가 발생합니다.

  • 하나는 블루투스 소켓의 단편화입니다.

  • 시스템은 통화가 통화를 처리하고 있다는 것을 알지 못하므로 통화를 시계 또는 Android Auto에 투사할 수 있도록 맞춤 블루투스 소켓과 맞춤 미디어 세션을 빌드해야 합니다.

  • 두 번째 문제는 오디오 포커스입니다.

  • 하위 수준 AudioManager API와 블루투스 API를 처리해야 하므로 오디오 누출이 발생하며 시스템의 우선순위가 아닙니다.

  • 앱에서 VoIP 통화를 진행 중인 경우 이동통신사 통화가 수신될 때마다 통화가 중단되고 모뎀이 오디오를 점유하여 앱에 오디오가 남지 않습니다.

  • 최악의 경우, 모바일 통화가 종료되더라도 오디오 포커스가 앱으로 반환되지 않습니다.

  • 세 번째 문제는 동기화입니다.

  • 사용자가 워치에서 음소거를 탭해도 휴대전화 UI에는 통화가 음소거 해제된 것으로 표시됩니다.

  • 이로 인해 큰 혼란이 발생합니다.

  • 사용자가 자동차 스티어링 칼럼에서 통화를 종료해도 휴대전화에는 통화 중 알림이 계속 표시됩니다.

  • 이런 일이 발생하면 사용자는 큰 불만을 느끼게 됩니다.

Jetpack Telecom 소개

  • 이러한 문제를 모두 해결하기 위해 Jetpack Telecom을 빌드했습니다.

  • 따라서 CallsManager를 사용하여 Jetpack Telecom에 통화를 한 번 등록하면 Jetpack Telecom이 중앙 시스템 통화 조정자 역할을 합니다.

  • 이 서비스는 사용자가 소유한 시계, Android Auto, 기타 원격 표시 경로에 통화를 투사하는 역할을 담당합니다.

  • 또한 오디오 포커스 처리도 담당합니다.

  • 무엇보다도 이 모든 기능이 Android 8까지 원활하게 작동합니다.

  • 통화를 시계나 자동차 화면에 투사하는 것은 절반에 불과합니다. 사용자는 당연히 이러한 기기에서 통화 상태를 제어하고 싶어 하기 때문입니다.

  • 필요에 따라 워치에서 통화를 음소거하거나 음소거를 해제하고 싶어 합니다.

  • 자동차 조향 칼럼에서 또는 이어폰을 두 번 탭하여 통화를 종료하려고 합니다.

  • Jetpack Telecom은 이러한 하드웨어 이벤트를 수신 대기하고 이를 한곳에서 콜백으로 표시합니다. 바로 CallControlScope입니다.

  • 이러한 콜백을 수신 대기하고 통화 엔진을 처리하고 그에 따라 통화 상태를 업데이트하기만 하면 됩니다.

  • Jetpack Telecom은 기기 간에 통화 상태를 동기화하는 역할을 담당합니다.

  • 따라서 내부적으로 라이브러리는 두 가지 버킷으로 이점을 제공합니다.

  • 첫째, 지금까지 설명한 모든 문제를 해결합니다.

  • 따라서 개발자가 하위 수준 AudioManager API를 처리하는 대신 라이브러리가 선언적 통화 엔드포인트를 제공하고 오디오 포커스를 관리하며 기기 간에 동기화합니다.

  • 기기 간 통화 전환을 담당합니다.

  • 또한 셀룰러 통화가 수신될 때도 처리합니다.

  • 하지만 이 라이브러리의 또 다른 이점은 Kotlin을 기반으로 빌드되었다는 점입니다. Jetpack Telecom으로 통화를 추가할 때마다 Kotlin 코루틴 범위가 열립니다.

  • 따라서 Kotlin 코루틴을 활용하고 Kotlin 코루틴은 통화 시간 동안 정확히 유지됩니다.

  • 따라서 통화와 관련하여 실행한 모든 작업은 통화가 연결 해제되는 즉시 자동으로 정리됩니다.

  • 따라서 불량 작업이 남지 않으며 호출 수명 주기도 라이브러리에서 처리됩니다.

  • 이제 이 멋진 라이브러리를 구현하고 싶으실 것입니다.

  • 간단한 3단계로 진행됩니다.

  • 1단계는 종속 항목을 선언하는 것입니다.

  • 그런 다음 필요한 권한인 MANAGE_OWN_CALLS를 선언하고 CallsManager 객체를 인스턴스화합니다.

    <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
  • CallsManager 객체를 인스턴스화한 후 영상 통화나 통화 스트리밍과 같이 앱에서 제공하려는 기능으로 앱을 등록합니다.

    // Initialize CallsManager & register supported capabilities once
    val callsManager = CallsManager(context)
    
    callsManager.registerAppWithTelecom(
        capabilities = CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING or
                       CallsManager.CAPABILITY_SUPPORTS_CALL_STREAMING
    )
  • 그런 다음 2단계입니다.

  • Jetpack Telecom에 앱을 등록했으므로 이제 수신 전화와 발신 전화를 처리할 차례입니다.

  • 이를 위해서는 CallAttributesCompat 객체를 빌드해야 합니다. 이 객체는 발신자 또는 수신자의 displayName, 발신자 엔드포인트인 주소, Jetpack Telecom이 이 원격 표면에 각각의 UI를 표시할 수 있도록 수신 전화인지 발신 전화인지 지정하는 방향, 필요한 리소스를 가져올 수 있도록 음성 통화인지 영상 통화인지 지정하는 callType와 같은 통화 메타데이터를 보유합니다.

    val attributes = CallAttributesCompat(
        displayName = "Kukk Nat",
        address = Uri.parse("tel:+16505550199"),
        direction = CallAttributesCompat.DIRECTION_INCOMING,
        callType = CallAttributesCompat.CALL_TYPE_AUDIO_CALL
    )
    
    callsManager.addCall(
        attributes, onAnswer, onDisconnect, onSetActive, onSetInactive
    ) {
        // Coroutine CallControlScope active for the duration of the call
    }
  • 메타데이터 객체가 준비되면 callsManager.addCall 메서드를 사용하여 추가합니다.

  • 그러면 통화 제어 코루틴 범위가 열립니다.

  • 통화를 추가한 후 3단계로 이동합니다.

  • 3단계는 원격 표시 경로에서 사용자가 수행할 작업을 수신 대기하는 것입니다.

  • CallControlScope가 있으므로 Kotlin 흐름을 수집하기만 하면 됩니다.

    callsManager.addCall(attributes, onAnswer, onDisconnect, onSetActive, onSetInactive) {
        // 1. React to hardware mute toggles from Watch, Car, or Headset
        launch {
            isMuted.collect { muted -> audioEngine.setMicMuted(muted) }
        }
    
        // 2. Observe available endpoints & request declarative switches
        launch {
            currentCallEndpoint.collect { endpoint -> updateUi(endpoint) }
        }
        requestEndpointChange(preferredBluetoothEndpoint)
    }
  • isMuted를 수집하여 사용자가 원격 표시 경로에서 통화를 음소거할 때마다 반응하고 이에 따라 통화 상태를 업데이트할 수 있습니다.

  • currentCallEndpoint을 수집하여 현재 통화가 진행 중인 엔드포인트를 파악하고 이에 따라 UI를 업데이트합니다.

  • 휴대전화 UI에 특정 기기를 표시할 수 있습니다.

  • 사용자가 한 기기에서 다른 기기로 변경하고 싶을 때마다 원하는 엔드포인트를 사용하여 requestEndpointChange 메서드를 호출하면 됩니다.

  • 아주 간단합니다.

  • 이 세 단계를 거치면 앱이 Jetpack Telecom을 통합하여 사용할 준비가 완료됩니다.

  • 그뿐만이 아닙니다.

  • Jetpack Telecom 1.1.0에서는 통합 통화 기록과 같은 더 많은 기능을 제공합니다. 즉, 이제 모든 VoIP 통화가 시스템 다이얼러에 기본적으로 표시됩니다.

  • 이 기능의 장점은 사용자가 시스템 다이얼러에서 바로 앱으로 원탭 콜백을 인스턴스화할 수 있다는 것입니다.

  • 따라서 모든 앱 중에서 특정 앱을 검색할 필요가 없습니다.

  • 익숙한 시스템 다이얼러에서 콜백을 시작하기만 하면 됩니다.

  • 하지만 일부 사용자의 경우, 세부적인 제어 기능도 제공하고 있습니다.

  • 통화 또는 특정 통화를 시스템에 기록하지 않으려면 CallAttributesCompat 객체 내에서 isLogExcluded 플래그를 true로 설정하면 됩니다.

  • 앞서 말씀드린 메타데이터 객체를 true로 설정하면 통화가 시스템에 기록되지 않고 시스템 다이얼러에 표시되지 않습니다.

  • 1.1.0에 추가될 두 번째 멋진 기능은 5G 네트워크 슬라이싱입니다.

  • 지원되는 이동통신사 네트워크에서는 네트워크가 프리미엄 네트워크 슬라이스를 제공합니다. 즉, 특정 사용 사례의 경우 이동통신사가 지연 시간이 짧고 중단이 없는 네트워크 슬라이스를 제공하므로 더 나은 품질과 더 나은 환경을 이용할 수 있습니다.

  • 따라서 지원되는 이동통신사 네트워크에서는 이 기능을 기본적으로 사용할 수 있으므로 추가 작업이 필요하지 않으며 코드 한 줄도 작성할 필요가 없습니다.

  • 라이브러리를 통합하면 통화에서 이러한 네트워크 슬라이스를 활용하여 지연 시간과 성능이 개선됩니다.

  • 하지만 어떤 이유로든 이 기능을 사용하지 않으려면 CAPABILITY_OPT_OUT_OF_PREMIUM_NETWORK 플래그를 사용하여 선택 해제할 수도 있습니다.

  • Jetpack Telecom은 이미 Google Meet, WhatsApp, 카카오톡, Zoho Arattai와 같은 전 세계 주요 커뮤니케이션 앱에서 구현되었습니다.

  • 이제 이 라이브러리를 앱에 통합하고 사용자에게 원활하고 더 나은 환경을 제공하는 것은 여러분의 몫입니다.

  • 실시간 통화에 대해 알아봤으니 이제 제 동료인 Yacine에게 마이크를 넘겨 탭하여 공유하기에 대해 알아보겠습니다.

  • 감사합니다.

P2P 공유 문제

  • 여러분, 조금 지치셨을 텐데 걱정하지 마세요. 마지막 섹션입니다.

  • P2P 파일 공유에 대해 다루겠지만, P2P 공유로 할 수 있는 다른 사용 사례도 있습니다.

  • 모바일 생태계 전반에서 매일 140억 개가 넘는 사진과 동영상이 소셜 미디어와 메시지 앱에서 공유되고 있습니다. 엄청난 양이죠.

  • 이러한 파일은 대부분 클라우드를 통해 공유됩니다. 사진을 찍어 선호하는 서비스를 통해 업로드하면 상대방이 다른 쪽에서 동일한 방식으로 다운로드해야 합니다.

  • 소셜 네트워크에 친구를 초대하거나 재생목록 또는 문서 공동작업에 누군가를 초대하려고 할 때도 마찬가지입니다. 보통 사용자 이름이나 이메일을 입력해야 하는데, 키보드로 입력하는 데 시간이 좀 걸립니다.

  • 실수를 할 수도 있고, 특히 두 사람이 말 그대로 서로 옆에 앉아 있을 때 클라우드를 통해 전송하고 기다려야 하는 것은 실제로 좋은 경험이 아닙니다.

  • 그다지 좋지 않습니다.

  • 요즘에는 누구나 멋진 휴대전화, 기기, 태블릿, 폴더블을 가지고 있으며, 이를 통해 멋진 사진을 찍습니다.

  • 하지만 일부 소셜 미디어나 메시지 앱을 통해 공유할 때는 성능 문제와 저장용량 문제로 인해 압축되기 때문에 결국 멋진 화면에서 화질이 떨어지거나 최상의 품질이 아닌 상태로 표시됩니다.

  • 프리미엄 기기에서 기대할 수 있는 최상의 환경은 아닙니다.

  • 두 번째는 파일이 실제로 상당히 크다는 것입니다.

  • 사진 한 장의 크기는 10MB, 5분짜리 동영상은 500MB 정도이므로 업로드하고 다운로드하는 데 시간이 꽤 걸립니다.

  • 마지막으로, 다른 쪽에서는 지연 시간을 기다려야 합니다.

  • 예를 들어 버스에 탔는데 사람이 많고 버스가 이동하면서 지연 시간이 길어지고 네트워크 범위가 좋지 않아 파일 전송이 중단되는 경우가 있습니다. 지연 시간이 너무 길어 시간 초과가 발생하고 파일 전송이 취소되는 경우도 있습니다. 이는 사용자에게 매우 실망스러운 경험입니다.

  • 따라서 모든 파일을 공유하기 위해 클라우드에 의존하고 모든 파일을 기다려야 하는 대신 블루투스나 Wi-Fi와 같은 로컬 네트워크를 사용하는 것이 좋습니다.

  • 하지만 직접 하려면 블루투스 관리자, Wi-Fi 관리자를 처리하고 자체 프로토콜을 작성해야 합니다. 모두가 이런 작업을 하고 싶어 하지는 않습니다.

  • 하지만 사실 Android에는 이미 존재하는 기능이 있습니다.

  • 이미 출시되어 들어보셨을 수도 있는 Quick Share입니다.

Quick Share 개요

  • Android의 Quick Share를 사용하면 시스템에 이미 이러한 추상화가 적용되어 있으므로 다른 기기와 파일을 공유하기만 하면 되며 이 모든 것을 관리할 필요가 없습니다.

  • 공유 시트에서 이미 사용할 수 있으며, 두 기기 간에 기기에 가장 적합한 프로토콜을 전환합니다. Marshmallow 이상에서 사용할 수 있으므로 요즘에는 상당히 많은 사용자가 사용할 수 있습니다.

  • 마지막으로, 측면에 표시되는 익숙한 UI와 UX는 사용자에게 큰 도움이 됩니다.

  • 따라서 UI가 무엇인지 알고 있으며, 사용 방법을 배울 필요가 없고, 이미 과거에 본 적이 있습니다.

  • 하지만 개발자들은 경험을 직접 소유하고 싶어 합니다.

  • 개발자님은 자신의 앱을 잘 알고 있으며, 아마도 저희보다 사용자를 더 잘 알고 계실 것입니다.

  • 그렇다면 앱에 Quick Share를 직접 추가해 보는 건 어떨까요?

  • 그래서 개발자들과 이야기를 나누기 시작했고, Quick Share가 이미 널리 사용되고 있다는 것을 알게 되었습니다.

  • Android, Chromebook, 최근에는 Google 도서, Windows에서도 사용할 수 있습니다. 30억 대가 넘는 기기에서 사용할 수 있는 엄청난 규모입니다.

  • 또한 Pixel 11에는 탭하여 공유하는 기능도 도입했습니다.

  • 따라서 공유할 기기를 선택할 필요 없이 탭하기만 하면 NFC를 통해 검색 가능성이 직접 제공됩니다.

  • 사진을 선택할 수도 있고 파일을 선택할 수도 있습니다. 두 기기를 탭하기만 하면 부딪히는 동작으로 바로 공유됩니다.

  • 그리고 경험을 소유하고 싶어 하는 개발자도 있습니다.

  • 이 방법이 사용자가 특정 기능(이 경우 파일 공유)을 사용할 수 있는 가장 좋은 방법이라는 것을 알고 있습니다.

  • 그렇다면 앱에 Quick Share를 삽입하는 것은 어떨까요?

  • 따라서 내년에는 Quick Share SDK를 직접 제공할 예정입니다.

  • 앱에서 직접 사용할 수 있으며, 두 기기 간의 엔드 투 엔드 암호화로 보안이 이미 내장되어 있습니다.

  • 전송은 로컬 네트워크 간에 이루어지므로 홈 Wi-Fi에서 최대 속도로 전송할 수 있으며, 두 기기 간에 핫스팟이 자동으로 생성되므로 사용자가 직접 처리할 필요가 없습니다.

  • Wi-Fi Direct를 지원하는 기기의 경우 이미 포함되어 있습니다.

  • 또한 뛰어난 카메라로 촬영한 파일은 최상의 해상도와 디테일을 유지하므로 대형 화면에서 선명하게 볼 수 있습니다.

  • 로컬 네트워크를 사용하기 때문에 지하철에서도, 심지어 가장 깊은 곳에서도 친구와 공유할 수 있는 네트워크가 있습니다.

  • 비행기에서도 마찬가지입니다. 하늘에서 수천 피트 상공에 있더라도 파일을 공유할 수 있습니다.

  • 마지막으로 개발자에게는 데이터 측면에서 엄청난 비용 절감 효과가 있습니다. 우선 사용자가 데이터 요금제를 사용할 필요가 없으며, 이는 관광객으로 여행할 때 큰 이점이 됩니다.

  • 또한 자체 서버에서 이를 유지관리할 필요가 없습니다.

  • 작년에는 개발자와 사용자에게 Quick Share를 통해 AirDrop으로 iPhone 기기와 파일을 공유할 수 있게 될 것이라고 안내했습니다.

  • Quick Share를 통해 개발자에게도 동일한 기능을 제공합니다.

  • iOS 앱을 보유한 개발자가 기기와 관계없이 두 플랫폼 간에 파일을 공유할 수 있도록 Quick Share iOS SDK를 제공할 예정입니다.

  • 지금까지는 멋진 프레젠테이션이었고, 이제 코드를 간단히 살펴보겠습니다.

  • 최대한 간단하게 만들려고 노력했습니다.

  • QuickShareClient를 시작하고 공유하려는 파일을 첨부해야 합니다. 세션을 시작하면 측면에 표시되는 이 아름다운 모달 창, 즉 사용자가 전송되는 파일을 미리보기로 볼 수 있는 하단 시트가 표시됩니다. 콘텐츠가 민감하다고 생각되면 화면에 표시되는 옵션으로 이를 사용 설정할 수도 있습니다.

    // Final API may undergo further updates
    val quickShareClient = QuickShareClient.create(activity)
    val attachment = FileAttachment(
      fileName = "report.pdf",
      size = 1_048_576L,
      uri = fileUri,
      mimeType = "application/pdf",
      // Sensitive content will be masked in preview
      isSensitiveContent = false,
    )
    
    val content = ShareableContent.withFiles(listOf(attachment))
    val request = SharingRequest.withContent(content)
    val session = quickShareClient.startSharingSession(request, lifecycleOwner)
  • 앱 내에서 파일을 수신하려는 경우에도 마찬가지입니다. 물론 이 경우에는 첨부파일을 추가하지 않으며, 세션이 시작되는 즉시 이와 관련된 어떤 작업도 처리할 필요가 없습니다.

    // Final API may undergo further updates
    val quickShareClient = QuickShareClient.create(activity)
    val request = SharingRequest.withoutContent()
    val session = quickShareClient.startSharingSession(request, lifecycleOwner)
    
    // Open the Quick Share overlay in receive mode
    session.launchWindow(activity, contentSelectionIntent = null)
  • 지금까지 양쪽에서 20줄의 코드만 살펴보았습니다.

  • Quick Share와 탭하여 공유를 사용하면 기기를 터치하는 것만으로 파일을 공유할 수 있어 새로운 사용 사례가 가능해집니다.

  • 다른 기기에서 앱이 종료될 수 있으며, 탭하면 파일이 전송되는 즉시 앱이 자동으로 열리고 URI를 수신하여 기기 내 처리를 수행하거나 앱 내에서 사용자에게 직접 무언가를 표시할 수 있습니다.

    // Final API may undergo further updates
    val quickShareClient = QuickShareClient.create(activity)
    val urlAttachment = TextAttachment(
      text = groupAddUrl,
      type = TextAttachment.Type.URL,
    )
    
    val content = ShareableContent.withText(urlAttachment)
    val appLink = AppLink("https://myapp.com/group/invite")
    val request = SharingRequest.withContent(content, appLink)
    val session = quickShareClient.startSharingSession(request, lifecycleOwner)
  • 따라서 수신 기기에서 앱을 열지 않아도 됩니다.

  • 탭하여 공유하면 자동으로 열립니다.

  • 물론 UI가 파일 전송에 반응하도록 하려면 Kotlin Flow를 사용하면 됩니다. 그러면 이를 처리할 수 있는 모든 이벤트가 제공됩니다.

    // Final API may undergo further updates
    scope.launch {
      for (event in session.sharingEvents) {
        when (event) {
          is SharingEvent.SendComplete ->
            println("Send finished")
          is SharingEvent.ReceiveComplete ->
            println("Received, files=${event.content?.files?.size}")
          else -> println("other event")
        }
      }
    }

요약 및 마무리

  • 지금까지 많은 내용을 살펴봤으니 이제 마무리하겠습니다.

  • 걱정하지 마세요. 잠시 휴식을 취할 수 있는 시간이 있습니다.

  • CompanionDeviceManager는 한루이가 앞서 설명한 것처럼 블루투스 상호작용, 지속적인 블루투스 상호작용을 처리하는 데 가장 적합한 API입니다.

  • 앱에 VoIP 통화가 있는 경우, 이것이 주요 사용 사례가 아니어도 됩니다. 예를 들어 사용자가 지원팀에 전화할 수 있는 지원 클라이언트가 앱에 포함되어 있을 수 있습니다.

  • 이 경우에도 Android에서 VoIP 통화를 처리하는 가장 좋은 방법은 Jetpack Telecom을 사용하는 것입니다.

  • 마지막으로, P2P 파일 공유 또는 앱 열기의 경우 앞서 언급했듯이 탭하여 공유하는 Quick Share가 해결책입니다.

  • 참석해 주신 모든 분께 감사드리며, 가시기 전에 한 가지 말씀드리자면, 세션이 끝난 후 밖에서 질문에 답변해 드릴 예정입니다.

  • 지금까지 소개해 드린 모든 사용 사례에 대해 궁금한 점이 있으면 상담 시간을 이용해 주세요.

  • 그리고 여러분의 강연이나 다른 세션에서 배운 내용을 소셜 미디어에 공유하는 것도 잊지 마세요.

  • #AndroidDevSummit이 있습니다.

  • 다시 한번 말씀드리지만, 참여가 이루어지고 있고 사람들이 이벤트를 정말 즐기고 있다는 것을 아는 것은 매우 중요합니다.

  • 다시 만나고 싶은 커뮤니티가 있다는 것을 알게 되었습니다.

  • 안내 데스크에서 Android 봇 키링에 추가할 수 있는 소셜 참을 받으세요.

  • 제대로 한 건가요?

  • 감사합니다.

코드

코드 스니펫

'호환 앱 개요'의 06:17에 있는 XML 스니펫
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
                 android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
'호환 앱 개요'의 06:34에 나오는 Kotlin 스니펫
class BleForegroundService : Service() {
   override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
      …
      return START_STICKY
   }
}
'호환 기기 관리자를 사용한 구현'의 8분 3초 지점에 있는 XML 스니펫
<uses-permission android:name="android.permission.REQUEST_COMPANION_PROFILE_MEDICAL" />
<uses-permission android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND" />
<uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" />
'호환 기기 관리자를 사용한 구현'의 08:38에 있는 Kotlin 스니펫
val paringRequest = AssociationRequest.Builder()
   .addDeviceFilter(deviceFilter)
   .setSingleDevice(true)
   .setDeviceProfile(AssociationRequest.DEVICE_PROFILE_MEDICAL)
   .build()

companionDeviceManager.associate(paringRequest, ContextCompat.getMainExecutor(this),
   object : CompanionDeviceManager.Callback() {
       override fun onAssociationPending(intentSender: IntentSender) {
           intentSenderLauncher.launch(IntentSenderRequest.Builder(intentSender).build())
       }

       override fun onAssociationCreated(associationInfo: AssociationInfo) { … }

       override fun onFailure(error: CharSequence?) { … }
   }
)
'호환 기기 관리자를 사용한 구현'의 09:47에 나오는 Kotlin 스니펫
class BleCompanionDeviceService : CompanionDeviceService() {

   override fun onDevicePresenceEvent(event: DevicePresenceEvent) {
       when (event.event) {
           DevicePresenceEvent.EVENT_BLE_APPEARED -> onDeviceAppeared(event.associationId)
           DevicePresenceEvent.EVENT_BLE_DISAPPEARED -> onDeviceDisappeared(event.associationId)
       }
   }

   private fun onDeviceAppeared(associationId: Int) { … }

   private fun onDeviceDisappeared(associationId: Int) { … }
}
'Jetpack Telecom 소개'의 18:29에 나오는 XML 스니펫
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
'Jetpack Telecom 소개'의 18:36에 나오는 Kotlin 스니펫
// Initialize CallsManager & register supported capabilities once
val callsManager = CallsManager(context)

callsManager.registerAppWithTelecom(
    capabilities = CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING or
                   CallsManager.CAPABILITY_SUPPORTS_CALL_STREAMING
)
'Jetpack Telecom 소개'의 18:55에 나오는 Kotlin 스니펫
val attributes = CallAttributesCompat(
    displayName = "Kukk Nat",
    address = Uri.parse("tel:+16505550199"),
    direction = CallAttributesCompat.DIRECTION_INCOMING,
    callType = CallAttributesCompat.CALL_TYPE_AUDIO_CALL
)

callsManager.addCall(
    attributes, onAnswer, onDisconnect, onSetActive, onSetInactive
) {
    // Coroutine CallControlScope active for the duration of the call
}
'Jetpack Telecom 소개'의 19:49에 나오는 Kotlin 스니펫
callsManager.addCall(attributes, onAnswer, onDisconnect, onSetActive, onSetInactive) {
    // 1. React to hardware mute toggles from Watch, Car, or Headset
    launch {
        isMuted.collect { muted -> audioEngine.setMicMuted(muted) }
    }

    // 2. Observe available endpoints & request declarative switches
    launch {
        currentCallEndpoint.collect { endpoint -> updateUi(endpoint) }
    }
    requestEndpointChange(preferredBluetoothEndpoint)
}
'Quick Share 개요'의 29:31에 있는 Kotlin 스니펫
// Final API may undergo further updates
val quickShareClient = QuickShareClient.create(activity)
val attachment = FileAttachment(
  fileName = "report.pdf",
  size = 1_048_576L,
  uri = fileUri,
  mimeType = "application/pdf",
  // Sensitive content will be masked in preview
  isSensitiveContent = false,
)

val content = ShareableContent.withFiles(listOf(attachment))
val request = SharingRequest.withContent(content)
val session = quickShareClient.startSharingSession(request, lifecycleOwner)
'Quick Share 개요'의 29:57에 있는 Kotlin 스니펫
// Final API may undergo further updates
val quickShareClient = QuickShareClient.create(activity)
val request = SharingRequest.withoutContent()
val session = quickShareClient.startSharingSession(request, lifecycleOwner)

// Open the Quick Share overlay in receive mode
session.launchWindow(activity, contentSelectionIntent = null)
'Quick Share 개요'의 30:23에 있는 Kotlin 스니펫
// Final API may undergo further updates
val quickShareClient = QuickShareClient.create(activity)
val urlAttachment = TextAttachment(
  text = groupAddUrl,
  type = TextAttachment.Type.URL,
)

val content = ShareableContent.withText(urlAttachment)
val appLink = AppLink("https://myapp.com/group/invite")
val request = SharingRequest.withContent(content, appLink)
val session = quickShareClient.startSharingSession(request, lifecycleOwner)
'Quick Share 개요'의 30:49에 있는 Kotlin 스니펫
// Final API may undergo further updates
scope.launch {
  for (event in session.sharingEvents) {
    when (event) {
      is SharingEvent.SendComplete ->
        println("Send finished")
      is SharingEvent.ReceiveComplete ->
        println("Received, files=${event.content?.files?.size}")
      else -> println("other event")
    }
  }
}