Wear OS 앱 만들기
이 플러그인의 설치 단계와 전체 마크다운 가이드가 포함된 설정 프롬프트를 복사하세요.
이 가이드는 Android의 Data Layer 경로와 동일한 방식으로 Capacitor phone 앱과 통신하는 Wear OS 컴패니언 앱 설정 방법을 안내합니다. CapgoWatchPlugin Android (play-services-wearable).
사전 요구사항
“사전 준비 사항” 섹션시작하기 전에 다음 사항을 확인하세요:
- Android Studio (최신 안정 버전)
- 기존 Capacitor Android 프로젝트 (
npx cap add android(필요한 경우) - 폰과 페어링된 실제 Wear OS 워치 (E2E 테스트 권장)
- 동일한 서명 키(signing key) 폰 앱 및 Wear OS 모듈 릴리스 빌드용
플러그인의 Wear OS 매핑 방식
“플러그인의 Wear OS 매핑 방식” 섹션휴대폰 쪽 플러그인 (CapgoWatchPlugin.java)은 다음 Data Layer 경로를 사용합니다:
| 경로 | 유형 | 목적 |
|---|---|---|
/capgo/message | 메시지 | 단방향 메시지 (폰 → 워치, 워치 → 폰) |
/capgo/message/withreply | 메시지 | JS 응답을 기대하는 워치→폰 메시지 replyToMessage |
/capgo/reply/{callbackId} | 메시지 | 워치로 반환되는 폰 응답 페이로드 |
/capgo/context | DataItem | 최신 애플리케이션 컨텍스트 (payload JSON 형식의 string) |
/capgo/userinfo/{uuid} | DataItem | 대기 중인 사용자 정보 (payload JSON 형식의 string) |
휴대전화가 다음 기능을 가진 워치 앱을 감지합니다: capgo_watch 기능 (WATCH_APP_CAPABILITY 플러그인 내 포함).
프로젝트 구조
“Project structure” 섹션이 가이드를 마치면 Android 트리 구조가 다음과 같이 구성됩니다:
디렉토리android/
디렉토리app/ (phone 모듈, Capacitor 호스트)
- …
디렉토리wear/ (새로운 Wear OS 모듈)
디렉토리src/main/
- AndroidManifest.xml
- java/…/WearMainActivity.kt
- java/…/CapgoWearBridge.kt
- res/values/wear.xml
- settings.gradle
1단계: Wear OS 모듈 추가
“1단계: Wear OS 모듈 추가” 섹션-
다음 폴더를
androidAndroid Studio에서 엽니다 (레포지토리 루트가 아님). -
선택 File → New → New Module…
-
선택 Wear OS → 빈 Wear App 생성하기 (또는 Wear OS 앱 (Compose를 선호하는 경우 사용).
-
모듈 설정:
- 모듈 이름:
wear(또는 원하는 이름) - 패키지 이름: 가능한 경우 휴대폰 앱의 네임스페이스와 일치시키세요
- 최소 SDK: 프로젝트 설정에 맞추세요 (플러그인
minSdk버전은 24입니다)
- 모듈 이름:
-
클릭 완료 그리고 Gradle sync를 진행하세요.
2단계: Gradle 의존성
“2단계: Gradle 의존성” 섹션Capacitor 플러그인은 이미 다음에 의존합니다. com.google.android.gms:play-services-wearable:18.2.0. 여러분의 wear 모듈에 동일한 API이(가) 필요합니다.
다음에서 wear/build.gradle (또는 wear/build.gradle.kts), 추가:
dependencies { implementation "com.google.android.gms:play-services-wearable:18.2.0" // Your UI dependencies (Compose, etc.)}수정 후 Gradle을 Sync 하세요.
다음 사항을 확인하세요: settings.gradle wear 모듈을 포함해야 합니다:
include ':app'include ':wear'실행 npx cap sync android 네이티브 모듈을 변경한 경우 Capacitor 프로젝트에서 가져온 후 Android Studio에서 다시 빌드하세요.
3단계: 다음을 선언합니다 capgo_watch capability
“3단계: capgo_watch capability 선언” 섹션생성 wear/src/main/res/values/wear.xml:
<?xml version="1.0" encoding="utf-8"?><resources xmlns:tools="http://schemas.android.com/tools"> <string-array name="android_wear_capabilities" translatable="false" tools:ignore="UnusedResources"> <item>capgo_watch</item> </string-array></resources>이를 통해 phone 플러그인이 설정할 수 있습니다 isWatchAppInstalled capability를 쿼리할 때 capgo_watch.
4단계: Wear 모듈 manifest 및 application ID
“4단계: Wear 모듈 매니페스트 및 application ID” 섹션동일한 값을 applicationId phone 앱과 wear 모듈에 사용하세요. 다음에서 설정합니다 wear/build.gradle (그리고 다음에서 일치시킵니다 android/app/build.gradle):
android { defaultConfig { applicationId "com.example.myapp" // must match android/app }}예시 wear/src/main/AndroidManifest.xml skeleton:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.type.watch" />
<application android:allowBackup="true" android:label="@string/app_name" android:supportsRtl="true" android:theme="@android:style/Theme.DeviceDefault">
<activity android:name=".WearMainActivity" android:exported="true" android:taskAffinity="" android:theme="@android:style/Theme.DeviceDefault"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application></manifest>5단계: Wear 쪽 브리지 (Kotlin)
“5단계: Wear-side 브릿지 (Kotlin)” 섹션생성 CapgoWearBridge.kt wear 패키지 내에 있습니다. 이는 다음의 경로를 미러링합니다: CapgoWatchPlugin.java.
Phone 플러그인은 각 요청에 대해 불투명한(opaque) callbackId 각각에 대해 /capgo/message/withreply 및 응답을 다음을 통해 전송합니다: /capgo/reply/{callbackId}. 워치 측에서는 해당 ID를 미리 알 수 없습니다. 동시 요청을 처리하려면 requestId JSON 페이로드 내에서, 휴대폰의 데이터를 다음과 같이 echo 하세요: replyToMessage, 그리고 해당 필드를 기준으로 워치에서 키 핸들러를 처리합니다.
package com.example.myapp.wear
import android.content.Contextimport android.net.Uriimport android.util.Logimport com.google.android.gms.wearable.CapabilityClientimport com.google.android.gms.wearable.DataClientimport com.google.android.gms.wearable.DataEventimport com.google.android.gms.wearable.DataEventBufferimport com.google.android.gms.wearable.DataMapItemimport com.google.android.gms.wearable.MessageClientimport com.google.android.gms.wearable.MessageEventimport com.google.android.gms.wearable.PutDataMapRequestimport com.google.android.gms.wearable.Wearableimport org.json.JSONObjectimport java.nio.charset.StandardCharsetsimport java.util.UUIDimport java.util.concurrent.ConcurrentHashMapimport java.util.concurrent.Executorsimport java.util.concurrent.TimeUnit
class CapgoWearBridge(private val context: Context) : MessageClient.OnMessageReceivedListener, DataClient.OnDataChangedListener {
companion object { private const val TAG = "CapgoWearBridge" const val CAPABILITY = "capgo_watch" const val PATH_MESSAGE = "/capgo/message" const val PATH_MESSAGE_WITH_REPLY = "/capgo/message/withreply" const val PATH_REPLY_PREFIX = "/capgo/reply/" const val PATH_CONTEXT = "/capgo/context" const val PATH_USER_INFO_PREFIX = "/capgo/userinfo/" }
private val executor = Executors.newSingleThreadExecutor() private val timeoutScheduler = Executors.newSingleThreadScheduledExecutor() private val messageClient = Wearable.getMessageClient(context) private val dataClient = Wearable.getDataClient(context) private val nodeClient = Wearable.getNodeClient(context)
var onMessage: ((JSONObject) -> Unit)? = null var onContext: ((JSONObject) -> Unit)? = null var onUserInfo: ((JSONObject) -> Unit)? = null
fun start() { messageClient.addListener(this) dataClient.addListener(this) Wearable.getCapabilityClient(context) .addLocalCapability(CAPABILITY) }
fun stop() { messageClient.removeListener(this) dataClient.removeListener(this) pendingReplyTimeouts.values.forEach { it.cancel(false) } pendingReplyTimeouts.clear() pendingReplyHandlers.clear() timeoutScheduler.shutdownNow() }
fun sendMessage(data: JSONObject, onError: (String) -> Unit = {}) { executor.execute { try { val nodes = com.google.android.gms.tasks.Tasks.await(nodeClient.connectedNodes) val payload = data.toString().toByteArray(StandardCharsets.UTF_8) for (node in nodes) { messageClient.sendMessage(node.id, PATH_MESSAGE, payload).let { com.google.android.gms.tasks.Tasks.await(it) } } } catch (e: Exception) { Log.e(TAG, "sendMessage failed", e) onError(e.message ?: "sendMessage failed") } } }
private val pendingReplyHandlers = ConcurrentHashMap<String, (JSONObject) -> Unit>() private val pendingReplyTimeouts = ConcurrentHashMap<String, java.util.concurrent.ScheduledFuture<*>>() private val outboundUserInfoPaths = ConcurrentHashMap.newKeySet<String>()
fun sendMessageWithReply( data: JSONObject, onReply: (JSONObject) -> Unit, onError: (String) -> Unit = {}, ) { executor.execute { val requestId = UUID.randomUUID().toString() try { val nodes = com.google.android.gms.tasks.Tasks.await(nodeClient.connectedNodes) if (nodes.isEmpty()) { onError("No connected phone node") return@execute } val payloadJson = JSONObject(data.toString()).apply { put("requestId", requestId) } pendingReplyHandlers[requestId] = onReply val timeout = timeoutScheduler.schedule( { if (pendingReplyHandlers.remove(requestId) != null) { pendingReplyTimeouts.remove(requestId) onError("Reply timed out after 5 minutes") } }, 5, TimeUnit.MINUTES, ) pendingReplyTimeouts[requestId] = timeout val node = nodes.first() com.google.android.gms.tasks.Tasks.await( messageClient.sendMessage( node.id, PATH_MESSAGE_WITH_REPLY, payloadJson.toString().toByteArray(StandardCharsets.UTF_8), ), ) } catch (e: Exception) { pendingReplyHandlers.remove(requestId) pendingReplyTimeouts.remove(requestId)?.cancel(false) Log.e(TAG, "sendMessageWithReply failed", e) onError(e.message ?: "sendMessageWithReply failed") } } }
fun updateApplicationContext(contextData: JSONObject, onError: (String) -> Unit = {}) { executor.execute { try { val request = PutDataMapRequest.create(PATH_CONTEXT) request.dataMap.putString("payload", contextData.toString()) request.setUrgent() com.google.android.gms.tasks.Tasks.await(dataClient.putDataItem(request.asPutDataRequest())) } catch (e: Exception) { Log.e(TAG, "updateApplicationContext failed", e) onError(e.message ?: "updateApplicationContext failed") } } }
fun transferUserInfo(userInfo: JSONObject, onError: (String) -> Unit = {}) { executor.execute { val path = PATH_USER_INFO_PREFIX + UUID.randomUUID() try { outboundUserInfoPaths.add(path) val request = PutDataMapRequest.create(path) request.dataMap.putString("payload", userInfo.toString()) request.setUrgent() com.google.android.gms.tasks.Tasks.await(dataClient.putDataItem(request.asPutDataRequest())) } catch (e: Exception) { outboundUserInfoPaths.remove(path) Log.e(TAG, "transferUserInfo failed", e) onError(e.message ?: "transferUserInfo failed") } } }
override fun onMessageReceived(event: MessageEvent) { val path = event.path val json = try { JSONObject(String(event.data, StandardCharsets.UTF_8)) } catch (e: Exception) { Log.w(TAG, "Ignoring non-JSON message on $path", e) return }
when { path == PATH_MESSAGE -> onMessage?.invoke(json) path.startsWith(PATH_REPLY_PREFIX) -> { val requestId = json.optString("requestId", "") if (requestId.isNotEmpty()) { pendingReplyTimeouts.remove(requestId)?.cancel(false) pendingReplyHandlers.remove(requestId)?.invoke(json) } } } }
override fun onDataChanged(dataEvents: DataEventBuffer) { for (event in dataEvents) { if (event.type != DataEvent.TYPE_CHANGED) continue val item = event.dataItem val path = item.uri.path ?: continue val map = DataMapItem.fromDataItem(item).dataMap val payload = map.getString("payload", "{}") val json = try { JSONObject(payload) } catch (e: Exception) { Log.w(TAG, "Ignoring non-JSON DataItem on $path", e) continue }
when { path == PATH_CONTEXT -> onContext?.invoke(json) path.startsWith(PATH_USER_INFO_PREFIX) -> { if (outboundUserInfoPaths.remove(path)) { continue } onUserInfo?.invoke(json) dataClient.deleteDataItems(item.uri) } } } }
}클라이언트 생성 시 브릿지를 연결하여 WearMainActivity.kt:
class WearMainActivity : ComponentActivity() { private lateinit var bridge: CapgoWearBridge
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) bridge = CapgoWearBridge(applicationContext) bridge.onMessage = { json -> Log.d("Wear", "phone message: $json") } bridge.onContext = { json -> Log.d("Wear", "context: $json") } bridge.onUserInfo = { json -> Log.d("Wear", "userInfo: $json") } bridge.start() setContent { /* your Compose UI */ } }
override fun onDestroy() { bridge.stop() super.onDestroy() }}사용 applicationContext 구성 변경(configuration changes) 시에도 리스너가 유지되도록 합니다.
6단계: Phone 쪽 Capacitor (TypeScript)
“Step 6: Phone-side Capacitor (TypeScript)” 섹션휴대폰 앱에 플러그인을 설치하고 sync 하세요 (iOS와 동일):
import { CapgoWatch } from '@capgo/capacitor-watch';
export async function setupWatchListeners() { await CapgoWatch.addListener('messageReceived', (event) => { console.log('Wear message:', event.message); });
await CapgoWatch.addListener('messageReceivedWithReply', async (event) => { const requestId = event.message.requestId; await CapgoWatch.replyToMessage({ callbackId: event.callbackId, data: { requestId, ok: true, echo: event.message }, }); });
await CapgoWatch.addListener('applicationContextReceived', (event) => { console.log('Wear context:', event.context); });
await CapgoWatch.addListener('userInfoReceived', (event) => { console.log('Wear userInfo:', event.userInfo); });}
export async function pingWatch() { const info = await CapgoWatch.getInfo(); if (!info.isSupported) { console.warn('Wear OS APIs not available on this device'); return; } if (!info.isReachable) { console.warn('No connected Wear OS nodes'); return; } await CapgoWatch.sendMessage({ data: { action: 'ping', at: Date.now() }, });}호출하세요 setupWatchListeners() 앱 부트스트랩 초기 단계(예: ... 이후) platform.ready()).
7단계: 빌드, 설치 및 테스트
“7단계: 빌드, 설치 및 테스트”라는 제목의 섹션-
다음을 선택하세요: 스마트폰
app실행 구성을 선택하고 실제 Android 폰에 설치합니다. -
다음을 선택하세요:
wear실행 구성을 선택하고 페어링된 워치(또는 폰 에뮬레이터와 연결된 워치 에뮬레이터)에 설치합니다. -
확인
getInfo()폰에서:isSupported: trueGoogle Play 서비스와 Wearable API를 사용할 수 있는 경우isReachable: true노드가 하나 이상 연결되었을 때isWatchAppInstalled: true워치가 advertise할 때capgo_watch
-
보내기
CapgoWatch.sendMessage폰에서 확인하고bridge.onMessage워치에서 확인합니다. -
워치에서 다음을 사용하여 전송:
sendMessageWithReply확인합니다.messageReceivedWithReply및replyToMessage제공합니다/capgo/reply/{callbackId}. -
테스트
updateApplicationContext및transferUserInfo위의 DataItem 경로를 사용해 양방향으로 테스트합니다.
문제 해결
“문제 해결” 섹션isSupported: false 실제 기기에서
- Google Play services가 없거나 버전이 낮음
- 기기에서 Wearable API 사용 불가
isReachable: false 하지만 워치가 페어링된 상태입니다
- Bluetooth가 비활성화되었거나 최근에 워치 앱을 실행하지 않음
- 다름
applicationId또는 모듈 간 서명 키가 다름 - 현재 Google Play 서비스에 연결된 Wear OS 노드가 없음
isWatchAppInstalled: false
- 워치에 Wear companion APK가 설치되지 않음
- 누락됨
capgo_watch항목이wear.xml - 에 없음
Capability 동기화 안 됨: capability 변경 후 wear APK를 재설치하세요
- 메시지가 전혀 전달되지 않음
/capgo/message경로는 반드시 일치해야 합니다 ( - Payload는 UTF-8 형식의 JSON 텍스트여야 합니다 (플러그인에서 사용)
JSONObject/JSObject문자열
Reply는 워치로 반환되지 않습니다
- Phone에서 호출해야 합니다
replyToMessage를 사용하여callbackId과messageReceivedWithReply - 동일한 값을 포함하세요
requestId워치가 동시 요청을 연관시키는 경우 reply JSON에 포함 - 대기 중인 reply는 다음 시간 후 만료됩니다 5분 Android의 경우 (플러그인 상수
PENDING_REPLY_TTL_MS)
다음 단계
“다음 단계” 섹션- watchOS 앱 만들기 Apple Watch 및 CapgoWatchSDK용
- 시작하기 공유 API 및 플랫폼 참고 사항
- 예시 전체 앱 패턴 확인
- 플러그인 소스 폰 쪽 경로 상수 확인
Creating a Wear OS app에서 계속하기
“Wear OS 앱 만들기에서 계속하기” 섹션사용 중이라면 Wear OS 앱 만들기 네이티브 플러그인 작업을 계획하려면, 이를 다음 항목과 연결하세요: @capgo/capacitor-watch 사용하기 @capgo/capacitor-watch를 사용하여 네이티브 기능을 구현하려면, Capgo 플러그인 디렉토리 Capgo Plugin Directory의 제품 워크플로우에 맞춰, Capgo의 Capacitor 플러그인 Capgo의 Capacitor 플러그인 구현 상세 내용에 대해서는, 플러그인 추가 또는 업데이트 플러그인 추가 또는 업데이트 구현 상세 내용에 대해서는, Ionic Enterprise 플러그인 대안 Ionic Enterprise 플러그인 대안의 제품 워크플로우에 대해서는.