본문 바로가기

Wear OS 앱 만들기

GitHub

이 가이드는 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/contextDataItem최신 애플리케이션 컨텍스트 (payload JSON 형식의 string)
/capgo/userinfo/{uuid}DataItem대기 중인 사용자 정보 (payload JSON 형식의 string)

휴대전화가 다음 기능을 가진 워치 앱을 감지합니다: capgo_watch 기능 (WATCH_APP_CAPABILITY 플러그인 내 포함).

이 가이드를 마치면 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 모듈 추가” 섹션
  1. 다음 폴더를 android Android Studio에서 엽니다 (레포지토리 루트가 아님).

  2. 선택 File → New → New Module…

  3. 선택 Wear OS → 빈 Wear App 생성하기 (또는 Wear OS 앱 (Compose를 선호하는 경우 사용).

  4. 모듈 설정:

    • 모듈 이름: wear (또는 원하는 이름)
    • 패키지 이름: 가능한 경우 휴대폰 앱의 네임스페이스와 일치시키세요
    • 최소 SDK: 프로젝트 설정에 맞추세요 (플러그인 minSdk 버전은 24입니다)
  5. 클릭 완료 그리고 Gradle sync를 진행하세요.

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.Context
import android.net.Uri
import android.util.Log
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.DataClient
import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataEventBuffer
import com.google.android.gms.wearable.DataMapItem
import com.google.android.gms.wearable.MessageClient
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.PutDataMapRequest
import com.google.android.gms.wearable.Wearable
import org.json.JSONObject
import java.nio.charset.StandardCharsets
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import 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()).

  1. 다음을 선택하세요: 스마트폰 app 실행 구성을 선택하고 실제 Android 폰에 설치합니다.

  2. 다음을 선택하세요: wear 실행 구성을 선택하고 페어링된 워치(또는 폰 에뮬레이터와 연결된 워치 에뮬레이터)에 설치합니다.

  3. 확인 getInfo() 폰에서:

    • isSupported: true Google Play 서비스와 Wearable API를 사용할 수 있는 경우
    • isReachable: true 노드가 하나 이상 연결되었을 때
    • isWatchAppInstalled: true 워치가 advertise할 때 capgo_watch
  4. 보내기 CapgoWatch.sendMessage 폰에서 확인하고 bridge.onMessage 워치에서 확인합니다.

  5. 워치에서 다음을 사용하여 전송: sendMessageWithReply 확인합니다. messageReceivedWithReply 및 replyToMessage 제공합니다 /capgo/reply/{callbackId}.

  6. 테스트 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)

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 플러그인 대안의 제품 워크플로우에 대해서는.