Langsung ke konten

Membuat aplikasi Wear OS

GitHub

Panduan ini akan memandu Anda membuat aplikasi pendamping Wear OS yang berkomunikasi dengan aplikasi ponsel Capacitor Anda melalui jalur Data Layer yang sama seperti CapgoWatchPlugin pada Android (play-services-wearable).

Sebelum memulai, pastikan Anda sudah memiliki:

  • Android Studio (versi stable terbaru)
  • Proyek Android Capacitor yang sudah ada (npx cap add android (jika diperlukan)
  • Smartwatch Wear OS fisik yang terhubung ke ponsel (disarankan untuk end-to-end testing)
  • Signing key yang sama untuk aplikasi ponsel dan release build modul Wear OS

Plugin di sisi ponsel (CapgoWatchPlugin.java) menggunakan rute Data Layer berikut:

RuteTipeTujuan
/capgo/messagePesanPesan satu arah (ponsel ke jam dan jam ke ponsel)
/capgo/message/withreplyPesanPesan dari jam ke ponsel yang mengharapkan balasan JS melalui replyToMessage
/capgo/reply/{callbackId}PesanPayload balasan ponsel kembali ke jam
/capgo/contextDataItemKonteks aplikasi terbaru (payload string berisi JSON)
/capgo/userinfo/{uuid}DataItemInfo user yang mengantre (payload string berisi JSON)

Ponsel mendeteksi aplikasi watch Anda dengan capgo_watch capability (WATCH_APP_CAPABILITY di dalam plugin).

Setelah menyelesaikan panduan ini, struktur Android Anda akan terlihat seperti ini:

  • Direktoriandroid/
    • Direktoriapp/ (modul ponsel, host Capacitor)
      • …
    • Direktoriwear/ (modul Wear OS baru)
      • Direktorisrc/main/
        • AndroidManifest.xml
        • java/…/WearMainActivity.kt
        • java/…/CapgoWearBridge.kt
        • res/values/wear.xml
    • settings.gradle
  1. Buka android folder di Android Studio (bukan root repo).

  2. Pilih File → New → New Module…

  3. Pilih Wear OS → Aplikasi Wear Kosong (atau Aplikasi Wear OS dengan Compose jika Anda mau).

  4. Konfigurasi modul:

    • Nama modul: wear (atau nama pilihan Anda)
    • Nama package: samakan dengan namespace aplikasi ponsel jika memungkinkan
    • Minimum SDK: sesuaikan dengan proyek Anda (plugin minSdk adalah 24)
  5. Klik Selesai dan biarkan Gradle melakukan sync.

Plugin Capacitor sudah bergantung pada com.google.android.gms:play-services-wearable:18.2.0. Anda wear modul membutuhkan API yang sama.

Di wear/build.gradle (atau wear/build.gradle.kts), tambahkan:

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

Sync Gradle setelah mengedit.

Pastikan settings.gradle menyertakan modul wear:

include ':app'
include ':wear'

Jalankan npx cap sync android dari proyek Capacitor Anda saat mengubah modul native, lalu rebuild di Android Studio.

Langkah 3: Deklarasikan capgo_watch capability

Bagian berjudul “Langkah 3: Deklarasikan capability capgo_watch”

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

Ini memungkinkan plugin ponsel mengatur isWatchAppInstalled kapan ia melakukan query capability capgo_watch.

Langkah 4: Manifest modul Wear dan application ID

Bagian berjudul “Langkah 4: Manifest modul Wear dan application ID”

Gunakan yang sama applicationId pada aplikasi ponsel dan modul wear. Atur di wear/build.gradle (dan samakan di android/app/build.gradle):

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

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

Buat CapgoWearBridge.kt di dalam package wear Anda. Ini mencerminkan path di CapgoWatchPlugin.java.

Plugin ponsel menetapkan sebuah callbackId untuk setiap /capgo/message/withreply dan mengirimkan balasannya pada /capgo/reply/{callbackId}. Watch tidak akan pernah melihat ID tersebut sebelumnya. Untuk request konkuren, sertakan requestId di payload JSON Anda, echo kembali dari ponsel di replyToMessage, dan handler key di jam tangan berdasarkan field tersebut.

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

Hubungkan bridge di 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()
}
}

Gunakan applicationContext saat membuat client agar listener tetap aktif setelah perubahan konfigurasi.

Langkah 6: Capacitor di sisi ponsel (TypeScript)

Bagian berjudul “Langkah 6: Capacitor di sisi ponsel (TypeScript)”

Install dan sync plugin di aplikasi ponsel (sama seperti 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() },
});
}

Panggil setupWatchListeners() di awal proses bootstrap aplikasi Anda (misalnya setelah platform.ready()).

  1. Pilih ponsel app konfigurasi run, install di ponsel Android fisik.

  2. Pilih wear konfigurasi run, install di jam tangan yang terhubung (atau emulator jam tangan yang terhubung ke emulator ponsel).

  3. Konfirmasi getInfo() di ponsel:

    • isSupported: true saat Google Play services dan Wearable API tersedia
    • isReachable: true saat setidaknya satu node terhubung
    • isWatchAppInstalled: true saat jam tangan melakukan advertising capgo_watch
  4. Kirim CapgoWatch.sendMessage dari ponsel dan verifikasi bridge.onMessage di jam tangan.

  5. Kirim dari jam tangan dengan sendMessageWithReply dan konfirmasi messageReceivedWithReply ditambah replyToMessage terkirim ke /capgo/reply/{callbackId}.

  6. Uji updateApplicationContext dan transferUserInfo di kedua arah menggunakan path DataItem di atas.

isSupported: false pada ponsel asli

  • Google Play services hilang atau kedaluwarsa
  • Wearable API tidak tersedia di perangkat

isReachable: false tapi jam sudah terhubung

  • Bluetooth mati, atau aplikasi jam belum dijalankan
  • Berbeda applicationId atau signing key antar modul berbeda
  • Tidak ada node Wear OS yang terhubung ke Google Play services

isWatchAppInstalled: false

  • APK companion Wear belum terinstal di jam
  • Hilang capgo_watch entri di wear.xml
  • Capability belum sinkron: instal ulang APK wear setelah mengubah capability

Pesan tidak pernah sampai

  • Path harus sama persis (/capgo/message, bukan custom prefix)
  • Payload harus berupa teks JSON UTF-8 (plugin menggunakan JSONObject / JSObject string)

Reply tidak akan pernah kembali ke watch

  • Phone harus memanggil replyToMessage dengan callbackId dari messageReceivedWithReply
  • Sertakan yang sama requestId di dalam reply JSON jika watch mengorelasikan request konkuren
  • Reply yang tertunda akan kedaluwarsa setelah 5 menit pada Android (konstanta plugin PENDING_REPLY_TTL_MS)

Jika Anda menggunakan Membuat aplikasi Wear OS untuk merencanakan pekerjaan plugin native, hubungkan dengan Menggunakan @capgo/capacitor-watch untuk kapabilitas native di Menggunakan @capgo/capacitor-watch, Direktori Plugin Capgo untuk alur kerja produk di Direktori Plugin Capgo, Plugin Capacitor oleh Capgo untuk detail implementasi pada Capacitor Plugins oleh Capgo Menambahkan atau Memperbarui Plugin untuk detail implementasi di Menambahkan atau Memperbarui Plugin, dan Alternatif Plugin Ionic Enterprise untuk alur kerja produk dalam Alternatif Plugin Ionic Enterprise.