앱 기본사항

WhatsApp의 Wear OS 여정에서 얻은 교훈

스피커

  • 마틴 펠란트

    Meta 소프트웨어 엔지니어

  • 수바그 바티아

    Meta 소프트웨어 엔지니어

정보

강연 소개

WhatsApp 엔지니어로부터 Wear OS 앱을 대규모로 업그레이드한 방법에 대해 직접 들어보세요. 이 세션에서는 Material 3를 통합하고 Wear 위젯을 채택하는 데 필요한 아키텍처 및 UX 결정사항을 다룹니다.

팀이 앱을 업데이트하고 일관된 사용자 환경을 제공하기 위해 사용한 교훈, 패턴, 도구에 대한 직접적인 정보를 얻을 수 있습니다.

요약

  • Android 휴대전화 코드를 Wear OS와 공유하면 중복 작업을 줄일 수 있지만 종속 항목 삽입과 매니페스트 재정의가 필요합니다. 모듈화된 기능이 자동으로 사라지지 않도록 테스트에서 멀티바인딩 세트를 비교해야 합니다.
  • 런타임 알림 브리징 관리는 중복 알림을 방지하면서 시계가 독립적으로 작동하도록 합니다. 로그인 시 브리징을 사용 중지하고 로그아웃 시 다시 사용 설정하면 휴대전화 미러링에서 진동이 두 번 울리지 않습니다.
  • 원형 디스플레이에서 Material 3로 이전하려면 시각적 스타일과 함께 동작 변경사항을 관리해야 합니다. TransformingLazyColumn을 올바르게 사용한다는 것은 한 수준에서만 변환을 적용하고 Compose의 remember로 위치를 캐시하지 않는 것을 의미합니다.
  • 시계가 즉시 업데이트되지 않으면 앱 만료 한도로 인해 활성 사용자가 갑자기 감소할 수 있습니다. 예약된 업데이트 알림은 90일 빌드가 만료되기 전에 사용자를 Google Play로 안내합니다.

Gemini Notebook

주제

  • wear-os
  • material-3
  • jetpack-compose
  • 모듈화
  • 알림
  • tiles-and-widgets

스크립트

소개

  • 안녕하세요.

  • 저는 마틴입니다.

  • 저는 수바그입니다.

  • 저희는 Meta에서 Wear OS용 WhatsApp을 개발하는 팀의 엔지니어입니다.

  • 최근에는 앱을 현대화했습니다.

  • 그래서 오늘은 공유 코드베이스로 이전하고 그 위에 새로운 환경을 구축하면서 얻은 교훈을 공유하고자 합니다.

  • Wear OS의 WhatsApp이 흥미로운 이유는 휴대전화 없이도 작동한다는 점입니다.

  • 연결되면 휴대전화와 마찬가지로 엔드 투 엔드 암호화가 적용되며 자체 데이터베이스와 서버 연결을 갖춘 독립적인 WhatsApp 기기로 작동합니다.

  • 따라서 휴대전화가 범위를 벗어나도 Wi-Fi 또는 LTE를 통해 계속 작동합니다.

  • 2023년에 Wear OS용 WhatsApp을 처음 출시했습니다.

  • 이 공격은 Rust로 작성된 소형 휴대용 클라이언트를 통해 이루어졌습니다.

  • 덕분에 프로덕션에 빠르게 적용할 수 있었습니다.

  • 하지만 이는 WhatsApp의 두 번째 독립적인 구현이었기 때문에 모든 프로토콜, 암호화, 기능을 두 번 빌드해야 했습니다.

  • 따라서 워치 앱은 항상 따라잡는 중이었습니다.

  • 휴대성이 뛰어나다는 점은 배경을 처리하는 데도 어려움을 주었습니다.

  • 클라이언트는 Android가 관리하려는 스레드, 타이머, 소켓을 자체적으로 처리했습니다.

  • Android에서는 백그라운드에 규칙이 있습니다.

  • 잠자기 모드에서는 네트워크가 끊깁니다.

  • 프로세스가 캐시되면 실행 시간이 제한되거나 실행 시간이 없습니다.

  • 이를 극복하려면 서비스, 작업 또는 푸시가 필요합니다.

  • 따라서 워치 앱은 이를 수동으로 연결해야 했습니다.

  • 하지만 Android 코드베이스는 이미 이 작업을 실행하고 있습니다.

  • 따라서 수십억 대의 기기에서 검증되고 이미 Android 규칙을 따르는 Android 코드베이스를 재사용하고 이전하기로 결정했습니다.

공유 코드베이스로 이전

  • 이렇게 하면 새 기능을 두 번 빌드할 필요가 없습니다.

  • 목표는 간단했습니다. 비즈니스 로직은 유지하고 UI는 삭제하는 것이었습니다.

  • 남은 것은 Kotlin 흐름을 API로 사용하는 헤드리스 라이브러리입니다.

  • 하지만 이 정도 규모의 앱을 한 번에 정리할 수는 없습니다.

  • 일부 모듈은 UI를 인프라와 혼합합니다.

  • 다른 라이브러리는 Wear OS 앱에 필요하지 않은 기능에 종속됩니다.

  • 그래서 우리는 두 가지 트랙으로 작업했습니다. 모듈화를 계속 진행하고, 아직 삭제할 수 없는 부분은 제거하거나 덮어썼습니다.

  • 모듈화부터 시작해 보겠습니다.

  • 빌드에서 코드를 제외하려면 간단한 '전화 앱인 경우' 문은 코드를 삭제하지 않으므로 작동하지 않습니다.

  • 따라서 DI, 특히 선택적 바인딩과 멀티바인딩에 크게 의존합니다.

  • 사내 DI를 사용하지만 Dagger가 제공하는 기능과 거의 일치합니다.

  • 예시는 Dagger로 작성되어 있습니다.

  • 선택적 결합(Dagger에서는 BindsOptionalOf)을 사용하면 종속 항목을 선택사항으로 선언할 수 있습니다(예: 백엔드 호출).

    @Module
    interface CallingModule {
        @BindsOptionalOf
        fun callingBackend(): CallingBackend
    }
    
    class Calls @Inject constructor(
        val backend: Optional<CallingBackend>,
    ) {
        fun start(): CallResult =
            backend.getOrNull()?.start()
                ?: CallResult.NotAllowed
    }
  • 그런 다음 시계 빌드에 구현을 제공하는 모듈이 포함되어 있으면 삽입된 백엔드가 표시되고 호출이 작동합니다.

  • 모듈을 생략하면 null이 되고 API에서 NotAllowed를 반환할 수 있습니다.

  • 멀티바인딩은 동일하게 작동하지만 세트에 적용됩니다.

    @Module
    interface CoreModule {
        @Multibinds
        fun handlers(): Set<MessageHandler>
    }
    
    @Module
    interface ReactionsModule {
        @Binds @IntoSet
        fun reactions(impl: ReactionHandler):
     MessageHandler
    }
    
    class MessageDispatcher @Inject constructor(
          private val handlers: Set<MessageHandler>,
    )
  • 예를 들어 MessageHandler와 같이 multibinds를 사용하여 집합을 선언할 수 있으며, 그런 다음 각 기능 모듈은 into set multibinding을 사용하여 자체 핸들러를 추가할 수 있습니다.

  • 결제를 지원하지 않으려면 예를 들어 빌드에서 전체 결제 모듈을 제외하면 되고, 삽입된 집합에는 해당 핸들러가 포함되지 않습니다.

  • 이렇게 하면 기능이 지원되는지 여부는 빌드 그래프의 문제입니다.

  • 하지만 이러한 분리는 양날의 검입니다.

  • 직접 참조를 사용하면 모듈을 삭제할 때 빌드가 중단됩니다.

  • 선택적 바인딩 또는 멀티바인딩을 사용하면 모듈을 참조하는 항목이 없으므로 빌드가 통과되고 기능이 자동으로 사라지거나 기본값이 적용됩니다.

  • 그리고 이 지점에 정확히 도달했습니다.

  • 리팩터링으로 인해 기능이 자체 모듈로 이동되었으며 빌드 앱과 워치용 앱에 이 기능이 포함되지 않았습니다.

  • 이로 인해 음성 메시지의 길이가 0초로 표시되었습니다.

  • 이런 일이 발생하지 않도록 하려면 어떻게 해야 할까요?

  • 체크를 다시 넣으려면 테스트가 필요합니다.

  • 이 경우 중요한 인터페이스에 대해 전화 앱과 워치용 앱 간의 멀티바인딩 세트를 비교합니다.

  • 이렇게 하면 올바른 구현이 항상 바인딩됩니다.

  • 이제 트랙 2에서는 아직 삭제할 수 없는 항목을 다룹니다.

  • 모듈을 재사용하면 작업이 줄어들지만, 선언하는 모든 항목이 매니페스트에 병합됩니다.

  • 휴대전화 런처 활동에서 보조 앱 아이콘을 추가하거나 불필요한 콘텐츠 제공자가 앱과 함께 시작됩니다.

  • 매니페스트 병합은 구성요소를 가져올 수 있는 유용한 tools:node="remove" 속성을 제공합니다.

    <!-- remove unused Main activity node -->
    <activity android:name="com.whatsapp.Main"
              tools:node="remove" />
    
    <!-- redirect any calls to a new Activity -->
    <activity-alias
         android:name="com.whatsapp.Main"
         android:targetActivity=".MyNewActivity" />
    
    <provider
    android:name="…MigrationContentProvider"
            tools:node="remove" />
  • 그리고 삭제되면 축소 도구에서 코드도 삭제할 수 있습니다.

  • 활동에 도달해야 하는 항목이 있는 경우 activity-alias를 사용하여 새 클래스로 리디렉션할 수 있습니다.

  • 하지만 이러한 속성은 우선순위가 낮은 매니페스트에만 적용되므로 병합 순서에 따라 삭제 여부가 결정됩니다.

  • 리소스도 같은 방식으로 작동합니다.

  • Reuse를 사용하면 전화 문자열을 무료로 사용할 수 있습니다.

  • 하지만 일부 앱은 시계에서 할 수 없는 기능을 약속합니다.

  • 모든 채팅의 시작 부분에 있는 암호화 알림의 끝부분을 예로 들어 보겠습니다.

  • 휴대전화에서는 '자세히 알아보기' 링크로 끝납니다.

  • 하지만 워치에는 링크 뒤에 아무것도 없습니다.

  • 따라서 시계의 우선순위가 높은 모듈에서 동일한 키를 다시 선언하고 문자열을 '휴대전화를 사용하여 자세히 알아보기'로 바꿉니다.

    <!-- phone -->
    <string name="…_disclosure">… Learn more</string>
    
    <!-- wearable override, same key -->
    <string name="…_disclosure">…Use your phone to learn more.</string>
  • 그런 다음 리소스 병합기가 Google의 리소스를 선택합니다.

  • 또한 실수로 문자열을 재정의하지 않도록 하는 방법도 필요합니다.

  • 따라서 재정의가 허용 목록에 없으면 실패하는 빌드 규칙을 만들었습니다.

알림 브리징 관리

  • 이제 알림에 대해 알아보겠습니다.

  • 기본적으로 Wear OS는 휴대전화 알림을 브리징하거나 미러링합니다.

  • 좋습니다. 하지만 휴대전화가 연결되어 있어야만 작동합니다.

  • 그리고 WhatsApp이 독립적으로 작동하기를 원하므로 앱에서 자체 알림을 게시합니다.

  • 하지만 브리징이 켜져 있으면 모든 메시지가 두 번 울립니다.

  • 브리지를 사용 중지하면 중복이 수정됩니다.

  • 정적 매니페스트 플래그가 있지만 사용자가 로그인하기 전에 설치부터 적용됩니다.

  • 따라서 설치하고 로그인하지 않으면 갑자기 모든 알림이 사라지는데, 이는 좋지 않습니다.

  • 따라서 BridgingManager를 사용하여 런타임에 설정합니다.

    isRegistered.collect {
      when (it) {
             // watch posts its own
        true  -> bridging.setEnabled(false)
     // phone bridges again
        false -> bridging.setEnabled(true)
      }
    }
    
    fun setEnabled(bridgingEnabled){
       bridgingManager.setConfig(
    BridgingConfig.Builder(context,
    bridgingEnabled)
          .build())
    }
  • 로그인하면 브리징이 사용 중지됩니다.

  • 로그아웃하면 다시 사용 설정됩니다.

  • 하지만 값을 다시 읽는 API는 없으며 시계에서 값이 손실되는 것을 확인했습니다.

  • 따라서 시계가 충전기에서 분리되거나 앱이 업데이트될 때마다 다시 작성합니다.

  • 마지막으로 로그아웃하는 방법에 대해 알아보겠습니다.

  • 계정에서 시계 연결을 해제하면 메시지도 함께 사라질 것으로 예상됩니다.

  • 따라서 메시지, 키, 설정 등 모든 항목을 삭제합니다.

  • 하지만 어떻게 안전하게 할 수 있을까요?

  • 백그라운드 스레드가 여전히 파일에 쓰는 동안 파일을 삭제하면 SQLite 예외가 발생하거나 새로 다시 생성된 데이터베이스에 쓰기가 발생할 수 있습니다.

  • 프로세스를 중지하지 않고는 프로세스 상태를 지울 수 없습니다.

  • Android는 데이터를 삭제하고 앱을 중지하는 clearApplicationUserData API를 제공하지만 권한도 재설정합니다.

  • 일부 OEM에서는 알림 브리징이 알림 권한과 연결되어 있으므로 이를 사용하면 휴대전화 미러링도 갑자기 중단됩니다.

  • 따라서 Google에서 직접 프로세스를 중지합니다.

    clearActivityBackStack()
    
    // enumerate our own, kill each
    killOtherProcesses()
    
    // no threads alive — now safe
    deleteDataDirectories()
    
    // show logged out confirmation
    firePostCleanupIntent()
  • DI와 WorkManager가 없는 보조 프로세스에 작은 서비스를 빌드하여 앱의 나머지 부분을 시작하지 않도록 합니다.

  • 로그아웃하면 이 서비스는 앱의 다른 모든 프로세스를 종료하고, 경합 상태가 없는 경우에만 모든 데이터를 삭제합니다.

  • clearApplicationUserData를 사용하지 않으므로 권한이 유지되고 알림 브리징을 사용 설정할 수 있으므로 휴대전화 알림이 다시 표시됩니다.

  • 여기까지가 첫 번째 부분입니다. 이제 수바그에게 넘겨 UI 업데이트에 관해 이야기하겠습니다.

Wear OS UI 현대화

  • 마틴님, 감사합니다.

  • 공유 인프라가 마련된 상태에서 Google은 WhatsApp이 손목에서 정말 편안하게 느껴지도록 하는 데 집중했습니다.

  • 이 여정의 첫 번째 단계는 Material 3로의 전환이었지만, 먼저 그 이유를 알아보겠습니다.

  • 모든 픽셀이 중요한 원형 디스플레이에서 Material 3는 손목에 맞게 WhatsApp을 현대화할 수 있는 적절한 구성요소와 디자인 기반을 제공했습니다.

  • 시각적 요소를 살펴보면 표면적으로는 UI 업데이트처럼 보일 수 있습니다.

  • Material 2에서 3로 전환하는 중이었는데, 하루아침에 이루어질 수 있는 일이 아니었습니다.

  • 이전 코드와 새 코드가 스위치 뒤에 나란히 존재해야 했습니다.

  • 그 결과 Figma와 시각적으로 일치하는 화면에서 여전히 일부 비정상적인 동작이 나타났습니다.

  • 예를 들어 크라운 스크롤, 오른쪽으로 스와이프하여 뒤로 가기 등 가장 기본적인 동작 중 일부가 작동하지 않았습니다.

  • 즉, 디자인 시스템 이전은 시각적 이전일 뿐만 아니라 동작 이전이기도 합니다.

  • 동작에 대해 이야기하자면, 가장자리 근처의 행을 보면 더 좁고 곡선에 들어가 있습니다.

  • 이것이 TransformingLazyColumn입니다.

  • 원형 베젤에 의해 잘리지 않도록 행이 변환됩니다.

  • 동적 화면은 상황을 까다롭게 만들 수 있습니다.

  • 새 메시지가 도착하고, 행이 이동하고, 다양한 모양의 풍선이 나타납니다.

  • 올바른 접근 방식이 무엇인지 살펴보겠습니다.

  • 왼쪽을 보면 말풍선이 너무 급격하게 줄어들고 있습니다.

  • 각 메시지는 내부적으로 카드 안에 래핑된 말풍선입니다.

  • 상위 카드에 변환을 적용하도록 지시했지만 동일한 변환이 하위 말풍선에도 전달되었습니다.

  • 그 결과 하위 말풍선은 의도한 변환의 두 배를 받게 됩니다.

  • 이 문제는 상위 카드에서 한 수준으로 변환을 적용하여 해결했습니다.

  • 이것 역시 핵심 원칙입니다.

  • 항목 하나에 변환 하나를 적용했습니다.

  • 또 다른 문제가 있습니다. 화면 중앙에 있는 풍선이 원래 크기로 표시되어야 하는데 작고 흐리게 표시됩니다.

  • 크기는 화면에서의 위치에 따라 달라집니다.

  • 초기 버전에서는 Compose의 remember 함수를 사용하여 크기 맞춤을 저장했지만 이전 위치도 저장되었습니다.

  • 이제 새 메시지가 도착하여 이전 버블이 위로 이동하면 변환은 여전히 이전 위치를 기반으로 합니다.

  • 이로 인해 버그가 발생합니다.

  • 이 문제를 해결하기 위해 remember 호출을 사용하여 크기를 저장하는 것을 중지하고 호출을 삭제했습니다.

  • 이 또한 배움입니다.

  • 행이 이동하면 변환도 함께 이동해야 합니다.

  • 여기에서 코드의 두 수정사항을 모두 확인할 수 있습니다.

    // FIX #1: ONE OWNER
    CompositionLocalProvider(
      LocalSurfaceTransformation provides null
    ) {
      M3Card(transformation = t) {
        SharedChatEntry(...)
      }
    }
    
    // FIX #2: CURRENT ITEM SCOPE
    val t = SurfaceTransformation(spec)
    // BUG #1: DOUBLE MORPH
    M3Card(transformation = t) {
      CompositionLocalProvider(
        LocalSurfaceTransformation provides t
      ) { SharedChatEntry(...) }
    }
    
    // BUG #2: STALE ITEM SCOPE
    val t = remember(spec) {
      SurfaceTransformation(spec)
    }
  • 왼쪽 상단에서는 변환이 두 수준에서 적용되고 있고 오른쪽 상단에서는 한 수준(M3 카드)에서만 적용되고 있습니다.

  • 왼쪽 하단에서는 remember 호출을 사용하여 변환을 캐시하지만 위치도 캐시합니다.

  • 오른쪽 하단에서는 매번 풍선의 최신 위치를 고려하여 변환을 계산합니다.

  • 보시다시피 모든 화면에서 일관된 환경이 제공되며, 주요 콘텐츠는 중앙에 유지되고 행은 가장자리 근처로 자연스럽게 이동합니다.

손목 알림 처리

  • 다음으로 알림에 대해 알아보겠습니다.

  • 이제 손목이 진동하고 시계를 확인하면 메시지가 표시된다고 상상해 보세요.

  • 하지만 잠시 후 손목에서 다시 진동이 울립니다.

  • 여러 알림을 받지만 확인할 때마다 동일한 내용이 표시됩니다.

  • 정말 짜증 나네요.

  • 이 문제를 해결하기 위해 두 가지 질문을 던졌습니다.

  • 어떤 이벤트가 사용자에게 알림을 보내야 하나요?

  • 어떤 이벤트가 알림을 자동으로 업데이트해야 하나요?

  • 이 버그는 백그라운드에서 프로필 사진 업데이트, 미디어 다운로드, 영수증이 알림을 자동으로 업데이트하는 대신 사용자에게 알림을 표시하기 때문에 발생했습니다.

  • 이제 새 메시지가 도착하면 손목이 정확히 한 번 진동하며, 알림이 필요하지 않은 이벤트는 알림을 조용히 새로고침합니다.

  • 이 또한 교훈입니다.

  • 알림이 핵심 사용자 여정 중 하나인 경우 각 알림이 사용자의 관심을 끌 만한 가치가 있는지 확인해야 합니다.

  • 알림에 관한 주제입니다.

  • 이전에는 WhatsApp Wear OS 알림을 통해 두 가지 작업을 할 수 있었습니다.

  • 메시지를 읽거나 앱을 열 수 있습니다.

  • 알림에 직접 답장하고 싶어도 이전에는 할 수 없었습니다.

  • 따라서 RemoteInput API와 함께 Android의 알림 작업을 사용하여 이 격차를 해소했습니다.

  • 이제 사용자는 입력하거나, 말하거나, 빠른 답장을 탭할 수 있습니다.

  • 하지만 전송 버튼을 누르는 것이 끝이 아닙니다.

  • 메시지가 전송되면 채팅이 읽음으로 표시되고 알림이 지워집니다.

  • 메시지 전송에 실패하면 한 번의 탭으로 다시 시도할 수 있습니다.

  • 이 기능을 개발하면서 얻은 또 다른 중요한 교훈은 알림 작업을 완전한 엔드 투 엔드 워크플로로 취급해야 한다는 것입니다.

타일에서 위젯으로 이전

  • 다음은 타일 및 위젯입니다.

  • 보시다시피 카드는 시계 화면에서 스와이프하여 액세스할 수 있는 전체 화면입니다.

  • 반면 위젯은 여러 개의 작은 카드로, 스크롤 가능한 화면 하나에 여러 개를 표시할 수 있습니다.

  • WhatsApp 타일과 위젯은 모두 동일한 기본 데이터를 기반으로 합니다.

  • 이것도 반전입니다.

  • 이 UI는 앱에서 그리는 것처럼 보이지만 실제로는 그렇지 않습니다.

  • UI와 데이터만 설명하면 시스템이 이를 그립니다.

  • 또한 아이콘을 탭해도 앱의 코드가 실행되지 않습니다.

  • 화면을 열도록 요청하는 것만 가능합니다.

  • 이것으로 강의를 마치겠습니다.

  • 시스템이 UI를 그리는 경우 이는 제품 및 테스트 측정항목의 일부입니다.

  • 프랭크와 유리가 위젯에 관한 강연에서 더 자세히 설명할 예정입니다.

업데이트 알림으로 활성 사용자 급감 방지

  • 마지막 슬라이드로 넘어가겠습니다. 한 걸음 물러서서 자문해 보겠습니다. 결국 우리는 더 많은 사용자가 우리 앱을 사용하기를 원합니다.

  • 이제 개발자로서 어느 날 대시보드에 로그인했는데 활성 사용자 수가 급격히 감소한 것을 발견했다고 가정해 보겠습니다.

  • 우선, 매우 우려스러운 상황입니다.

  • 그런 다음 디버깅을 시작할 수 있습니다.

  • 비정상 종료가 발생했거나 잘못된 출시가 진행 중이라고 느낄 수 있습니다.

  • 하지만 저희는 이 중 어느 것도 해당하지 않았습니다.

  • 모든 WhatsApp 앱 버전에는 90일의 기본 만료 기간이 있는 것으로 확인되었습니다.

  • 따라서 사용자가 이전 빌드에 머물러 있는 동안 새 빌드가 시계에 도달하는 데 시간이 걸린다고 가정해 보겠습니다.

  • 따라서 사용자가 앱을 종료하지 않았지만 앱이 작동을 중지합니다.

  • 그래서 앱 업데이트를 제품의 일부로 만들었습니다.

  • 화면에 표시된 것처럼 앱 만료 15일 전에 이 메시지가 화면에 표시됩니다.

  • 그런 다음 7일, 5일, 3일, 2일, 1일이 남았을 때 다시 표시됩니다.

  • 사용자는 이를 닫거나 Play 스토어로 이동하여 앱을 업데이트할 수 있습니다.

  • 이렇게 하면 사용자에게 앱 만료가 다가오고 있음을 여러 번 알리고 앱을 업데이트할 기회를 여러 번 제공할 수 있습니다.

  • 이것이 마지막 학습입니다.

  • 업데이트를 100% 출시했다고 해서 개발자의 작업이 완료되는 것은 아닙니다.

  • 사용자가 실제로 업데이트를 받아야만 완료됩니다.

  • 이것으로 세션을 마치겠습니다.

  • 대화를 계속 이어가고 싶다면 실무자와의 만남 시간에 언제든지 인사해 주세요.

  • 다음으로 프랭크와 유리가 Wear 위젯에 관해 이야기하겠습니다.

  • 감사합니다.

코드

코드 스니펫

'공유 코드베이스로 이동'의 02:27에 있는 Kotlin 스니펫
@Module
interface CallingModule {
    @BindsOptionalOf
    fun callingBackend(): CallingBackend
}

class Calls @Inject constructor(
    val backend: Optional<CallingBackend>,
) {
    fun start(): CallResult =
        backend.getOrNull()?.start()
            ?: CallResult.NotAllowed
}
'공유 코드베이스로 이동'의 2분 47초 지점에 있는 Kotlin 스니펫
@Module
interface CoreModule {
    @Multibinds
    fun handlers(): Set<MessageHandler>
}

@Module
interface ReactionsModule {
    @Binds @IntoSet
    fun reactions(impl: ReactionHandler):
 MessageHandler
}

class MessageDispatcher @Inject constructor(
      private val handlers: Set<MessageHandler>,
)
'공유 코드베이스로 이동'의 04:16에 있는 XML 스니펫
<!-- remove unused Main activity node -->
<activity android:name="com.whatsapp.Main"
          tools:node="remove" />

<!-- redirect any calls to a new Activity -->
<activity-alias
     android:name="com.whatsapp.Main"
     android:targetActivity=".MyNewActivity" />

<provider
android:name="…MigrationContentProvider"
        tools:node="remove" />
'공유 코드베이스로 이전'의 5분 3초 지점에 있는 XML 스니펫
<!-- phone -->
<string name="…_disclosure">… Learn more</string>

<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>
'알림 브리징 관리'의 05:59에 나오는 Kotlin 스니펫
isRegistered.collect {
  when (it) {
         // watch posts its own
    true  -> bridging.setEnabled(false)
 // phone bridges again
    false -> bridging.setEnabled(true)
  }
}

fun setEnabled(bridgingEnabled){
   bridgingManager.setConfig(
BridgingConfig.Builder(context,
bridgingEnabled)
      .build())
}
'알림 브리징 관리'의 7분 6초 지점에 있는 Kotlin 스니펫
clearActivityBackStack()

// enumerate our own, kill each
killOtherProcesses()

// no threads alive — now safe
deleteDataDirectories()

// show logged out confirmation
firePostCleanupIntent()
'Modernizing the Wear OS UI'의 10:30에 나오는 Kotlin 스니펫
// FIX #1: ONE OWNER
CompositionLocalProvider(
  LocalSurfaceTransformation provides null
) {
  M3Card(transformation = t) {
    SharedChatEntry(...)
  }
}

// FIX #2: CURRENT ITEM SCOPE
val t = SurfaceTransformation(spec)
'Modernizing the Wear OS UI'의 10:30에 나오는 Kotlin 스니펫
// BUG #1: DOUBLE MORPH
M3Card(transformation = t) {
  CompositionLocalProvider(
    LocalSurfaceTransformation provides t
  ) { SharedChatEntry(...) }
}

// BUG #2: STALE ITEM SCOPE
val t = remember(spec) {
  SurfaceTransformation(spec)
}