Vai al contenuto

Creazione di un'app Wear OS

GitHub

Questa guida ti spiega come creare un'app companion per Wear OS che comunica con la tua app Capacitor su smartphone tramite gli stessi percorsi Data Layer utilizzati CapgoWatchPlugin su Android (play-services-wearable).

Prima di iniziare, assicurati di avere:

  • Android Studio (ultima versione stabile)
  • Un progetto Android Capacitor esistente (npx cap add android (se necessario)
  • Uno smartwatch Wear OS fisico associato al telefono (consigliato per test end-to-end)
  • La stessa signing key per le release build dell'app del telefono e del modulo Wear OS

Come il plugin si mappa su Wear OS

Sezione: “Come il plugin si mappa su Wear OS”

Il plugin lato smartphone (CapgoWatchPlugin.java) utilizza queste rotte Data Layer:

RottaTipoScopo
/capgo/messageMessaggioMessaggi unidirezionali (da telefono a watch e viceversa)
/capgo/message/withreplyMessaggioMessaggi da watch a telefono che richiedono una risposta JS tramite replyToMessage
/capgo/reply/{callbackId}MessaggioPayload di risposta dal telefono al watch
/capgo/contextDataItemUltimo contesto applicativo (payload string in formato JSON)
/capgo/userinfo/{uuid}DataItemInfo utente in coda (payload string in formato JSON)

Il telefono rileva l'app per il watch con la capgo_watch capability (WATCH_APP_CAPABILITY nel plugin).

Dopo aver completato questa guida, la struttura Android sarà questa:

  • Directoryandroid/
    • Directoryapp/ (modulo telefono, host Capacitor)
      • …
    • Directorywear/ (nuovo modulo Wear OS)
      • Directorysrc/main/
        • AndroidManifest.xml
        • java/…/WearMainActivity.kt
        • java/…/CapgoWearBridge.kt
        • res/values/wear.xml
    • settings.gradle
  1. Apri la android cartella in Android Studio (non la root del repo).

  2. Usa File → Nuovo → Nuovo modulo…

  3. Seleziona Wear OS → App Wear OS vuota (o App Wear OS con Compose, se preferisci).

  4. Configura il modulo:

    • Nome del modulo: wear (o il nome che preferisci)
    • Nome del pacchetto: deve corrispondere al namespace dell'app per telefono, se possibile
    • Minimo SDK: allinealo al tuo progetto (il plugin minSdk è 24)
  5. Clicca su Fine e attendi il sync di Gradle.

Il plugin Capacitor dipende già da com.google.android.gms:play-services-wearable:18.2.0. La tua wear il modulo richiede lo stesso API.

In wear/build.gradle (o wear/build.gradle.kts), aggiungi:

dependencies {
implementation "com.google.android.gms:play-services-wearable:18.2.0"
// Your UI dependencies (Compose, etc.)
}

Sincronizza Gradle dopo la modifica.

Assicurati che settings.gradle includa il modulo wear:

include ':app'
include ':wear'

Esegui npx cap sync android dal tuo progetto Capacitor quando modifichi i moduli nativi, quindi esegui il rebuild in Android Studio.

Passaggio 3: Dichiara la capgo_watch capacità

Sezione: “Passaggio 3: Dichiara la capability capgo_watch”

Crea 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>

Questo permette al plugin del telefono di impostare isWatchAppInstalled quando interroga la capability capgo_watch.

Passo 4: Manifest del modulo Wear e application ID

Sezione: “Passaggio 4: Manifest del modulo Wear e application ID”

Usa lo stesso applicationId nell'app del telefono e nel modulo Wear. Impostalo in wear/build.gradle (e assicurati che coincida in android/app/build.gradle):

android {
defaultConfig {
applicationId "com.example.myapp" // must match android/app
}
}

Esempio 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>

Crea CapgoWearBridge.kt nel tuo pacchetto wear. Specchia i percorsi in CapgoWatchPlugin.java.

Il plugin per telefono assegna un callbackId per ogni /capgo/message/withreply e invia la risposta su /capgo/reply/{callbackId}. La watch non vede mai quell'ID in anticipo. Per richieste concorrenti, includi un requestId nel tuo payload JSON, restituiscilo dal telefono in replyToMessage, e gestisci i key handler sull'orologio in base a quel campo.

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)
}
}
}
}
}

Collega il 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()
}
}

Usa applicationContext quando crei i client, così i listener sopravvivono ai cambi di configurazione.

Passo 6: Capacitor lato phone (TypeScript)

Sezione: “Passo 6: Capacitor lato telefono (TypeScript)”

Installa e sincronizza il plugin nell'app del telefono (come su 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() },
});
}

Chiama setupWatchListeners() all'inizio del bootstrap dell'app (ad esempio dopo platform.ready()).

  1. Seleziona la telefono app configurazione di esecuzione, installa su un telefono Android fisico.

  2. Seleziona la wear configurazione di esecuzione, installa sullo smartwatch associato (o su un emulatore di smartwatch collegato all'emulatore del telefono).

  3. Verifica getInfo() sul telefono:

    • isSupported: true quando i Google Play services e Wearable API sono disponibili
    • isReachable: true quando almeno un nodo è connesso
    • isWatchAppInstalled: true quando l'orologio trasmette capgo_watch
  4. Invia CapgoWatch.sendMessage dal telefono e verifica bridge.onMessage sull'orologio.

  5. Invia dall'orologio con sendMessageWithReply e conferma messageReceivedWithReply più replyToMessage consegna su /capgo/reply/{callbackId}.

  6. Test updateApplicationContext e transferUserInfo in entrambe le direzioni usando i percorsi DataItem sopra.

isSupported: false su un telefono reale

  • Google Play services mancanti o obsoleti
  • API Wearable non disponibile sul dispositivo

isReachable: false ma l'orologio è accoppiato

  • Bluetooth disattivato o app dell'orologio non avviata di recente
  • Diversi applicationId o chiavi di firma diverse tra i moduli
  • Nessun nodo Wear OS connesso ai Google Play services

isWatchAppInstalled: false

  • APK companion Wear non installato sull'orologio
  • Mancante capgo_watch voce in wear.xml
  • Capability non sincronizzata: reinstalla l'APK Wear dopo aver modificato le capability

I messaggi non arrivano mai

  • Il percorso deve corrispondere esattamente a (/capgo/message, non un prefisso personalizzato)
  • Il payload deve essere un testo JSON in UTF-8 (il plugin usa JSONObject / JSObject stringhe)

La risposta non torna mai all'orologio

  • Il telefono deve chiamare replyToMessage con il callbackId da messageReceivedWithReply
  • Includi lo stesso requestId nel JSON di risposta se l'orologio correla richieste concorrenti
  • Le risposte in sospeso scadono dopo 5 minuti su Android (costante del plugin PENDING_REPLY_TTL_MS)

Se stai usando Creazione di un'app Wear OS per pianificare il lavoro sui plugin nativi, collegalo a Utilizzo di @capgo/capacitor-watch per la funzionalità nativa in Utilizzo di @capgo/capacitor-watch, Directory dei plugin di Capgo per il workflow del prodotto nella Capgo Plugin Directory, Plugin Capacitor di Capgo per i dettagli di implementazione in Capacitor Plugins di Capgo, Aggiunta o aggiornamento dei plugin per i dettagli su come aggiungere o aggiornare i plugin, e Alternative ai plugin Ionic Enterprise per il workflow del prodotto in Alternative ai plugin Ionic Enterprise.