Creating a Wear OS app
Copy a setup prompt with the install steps and the full markdown guide for this plugin.
This guide walks you through a Wear OS companion app that talks to your Capacitor phone app through the same Data Layer paths as CapgoWatchPlugin on Android (play-services-wearable).
Prerequisites
Section titled “Prerequisites”Before you begin, ensure you have:
- Android Studio (latest stable)
- An existing Capacitor Android project (
npx cap add androidif needed) - A physical Wear OS watch paired with the phone (recommended for end-to-end testing)
- The same signing key for the phone app and the Wear OS module release builds
How the plugin maps to Wear OS
Section titled “How the plugin maps to Wear OS”The phone-side plugin (CapgoWatchPlugin.java) uses these Data Layer routes:
| Route | Type | Purpose |
|---|---|---|
/capgo/message | Message | One-way messages (phone to watch and watch to phone) |
/capgo/message/withreply | Message | Watch to phone messages that expect a JS reply via replyToMessage |
/capgo/reply/{callbackId} | Message | Phone reply payload back to the watch |
/capgo/context | DataItem | Latest application context (payload string holds JSON) |
/capgo/userinfo/{uuid} | DataItem | Queued user info (payload string holds JSON) |
The phone detects your watch app with the capgo_watch capability (WATCH_APP_CAPABILITY in the plugin).
Project structure
Section titled “Project structure”After completing this guide, your Android tree will look like this:
Directoryandroid/
Directoryapp/ (phone module, Capacitor host)
- …
Directorywear/ (new Wear OS module)
Directorysrc/main/
- AndroidManifest.xml
- java/…/WearMainActivity.kt
- java/…/CapgoWearBridge.kt
- res/values/wear.xml
- settings.gradle
Step 1: Add a Wear OS module
Section titled “Step 1: Add a Wear OS module”-
Open the
androidfolder in Android Studio (not the repo root). -
Choose File → New → New Module…
-
Select Wear OS → Empty Wear App (or Wear OS App with Compose if you prefer).
-
Configure the module:
- Module name:
wear(or your preferred name) - Package name: match your phone app namespace when possible
- Minimum SDK: align with your project (plugin
minSdkis 24)
- Module name:
-
Click Finish and let Gradle sync.
Step 2: Gradle dependencies
Section titled “Step 2: Gradle dependencies”The Capacitor plugin already depends on com.google.android.gms:play-services-wearable:18.2.0. Your wear module needs the same API.
In wear/build.gradle (or wear/build.gradle.kts), add:
dependencies { implementation "com.google.android.gms:play-services-wearable:18.2.0" // Your UI dependencies (Compose, etc.)}Sync Gradle after editing.
Ensure settings.gradle includes the wear module:
include ':app'include ':wear'Run npx cap sync android from your Capacitor project when you change native modules, then rebuild in Android Studio.
Step 3: Declare the capgo_watch capability
Section titled “Step 3: Declare the capgo_watch capability”Create 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>This lets the phone plugin set isWatchAppInstalled when it queries capability capgo_watch.
Step 4: Wear module manifest and application ID
Section titled “Step 4: Wear module manifest and application ID”Use the same applicationId on the phone app and the wear module. Set it in wear/build.gradle (and match it in android/app/build.gradle):
android { defaultConfig { applicationId "com.example.myapp" // must match android/app }}Example 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>Step 5: Wear-side bridge (Kotlin)
Section titled “Step 5: Wear-side bridge (Kotlin)”Create CapgoWearBridge.kt in your wear package. It mirrors the paths in CapgoWatchPlugin.java.
The phone plugin assigns an opaque callbackId for each /capgo/message/withreply and sends the reply on /capgo/reply/{callbackId}. The watch never sees that ID ahead of time. For concurrent requests, include a requestId in your JSON payload, echo it from the phone in replyToMessage, and key handlers on the watch by that field.
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) } } } }
}Wire the bridge in 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() }}Use applicationContext when constructing clients so listeners survive configuration changes.
Step 6: Phone-side Capacitor (TypeScript)
Section titled “Step 6: Phone-side Capacitor (TypeScript)”Install and sync the plugin on the phone app (same as 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() }, });}Call setupWatchListeners() early in your app bootstrap (for example after platform.ready()).
Step 7: Build, install, and test
Section titled “Step 7: Build, install, and test”-
Select the phone
apprun configuration, install on a physical Android phone. -
Select the
wearrun configuration, install on the paired watch (or a watch emulator linked to the phone emulator). -
Confirm
getInfo()on the phone:isSupported: truewhen Google Play services and Wearable API are availableisReachable: truewhen at least one node is connectedisWatchAppInstalled: truewhen the watch advertisescapgo_watch
-
Send
CapgoWatch.sendMessagefrom the phone and verifybridge.onMessageon the watch. -
Send from the watch with
sendMessageWithReplyand confirmmessageReceivedWithReplyplusreplyToMessagedelivers on/capgo/reply/{callbackId}. -
Test
updateApplicationContextandtransferUserInfoin both directions using the DataItem paths above.
Troubleshooting
Section titled “Troubleshooting”isSupported: false on a real phone
- Google Play services missing or outdated
- Wearable API unavailable on the device
isReachable: false but the watch is paired
- Bluetooth disabled, or watch app not running recently
- Different
applicationIdor signing keys between modules - No Wear OS node currently connected to Google Play services
isWatchAppInstalled: false
- Wear companion APK not installed on the watch
- Missing
capgo_watchentry inwear.xml - Capability not synced: reinstall the wear APK after changing capabilities
Messages never arrive
- Path must match exactly (
/capgo/message, not a custom prefix) - Payload must be JSON text in UTF-8 (the plugin uses
JSONObject/JSObjectstrings)
Reply never returns to the watch
- Phone must call
replyToMessagewith thecallbackIdfrommessageReceivedWithReply - Include the same
requestIdin the reply JSON if the watch correlates concurrent requests - Pending replies expire after 5 minutes on Android (plugin constant
PENDING_REPLY_TTL_MS)
Next steps
Section titled “Next steps”- Creating a watchOS app for Apple Watch and CapgoWatchSDK
- Getting started for shared API and platform notes
- Examples for full app patterns
- Plugin source for phone-side path constants
Keep going from Creating a Wear OS app
Section titled “Keep going from Creating a Wear OS app”If you are using Creating a Wear OS app to plan native plugin work, connect it with Using @capgo/capacitor-watch for the native capability in Using @capgo/capacitor-watch, Capgo Plugin Directory for the product workflow in Capgo Plugin Directory, Capacitor Plugins by Capgo for the implementation detail in Capacitor Plugins by Capgo, Adding or Updating Plugins for the implementation detail in Adding or Updating Plugins, and Ionic Enterprise Plugin Alternatives for the product workflow in Ionic Enterprise Plugin Alternatives.