feat(watch-connectivity): add WearOS support alongside Apple Watch

This commit is contained in:
2026-08-08 12:00:02 +02:00
parent 2211b8ebe4
commit 831ec6b836
52 changed files with 2278 additions and 62 deletions
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="nl.guidoit.swimbuddy.wear">
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<service
android:name=".WearMessageListenerService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/swimbuddy" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,145 @@
package nl.guidoit.swimbuddy.wear
import com.google.android.gms.wearable.MessageClient
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.NodeClient
import com.google.android.gms.wearable.Wearable
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.tasks.await
import org.json.JSONObject
class WearConnectivityModule : Module() {
private var messageClient: MessageClient? = null
private var nodeClient: NodeClient? = null
private val pendingEvents = mutableListOf<Map<String, Any?>>()
private val MESSAGE_PATH = "/swimbuddy"
companion object {
var instance: WearConnectivityModule? = null
private set
}
override fun definition() = ModuleDefinition {
Name("WearConnectivity")
OnCreate {
instance = this@WearConnectivityModule
val ctx = appContext.reactContext ?: return@OnCreate
messageClient = Wearable.getMessageClient(ctx)
nodeClient = Wearable.getNodeClient(ctx)
}
AsyncFunction("isSupported") {
try {
val ctx = appContext.reactContext ?: return@AsyncFunction false
com.google.android.gms.common.GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(ctx) == com.google.android.gms.common.ConnectionResult.SUCCESS
} catch (e: Exception) {
false
}
}
AsyncFunction("isPaired") {
try {
val nodes = nodeClient?.connectedNodes?.await() ?: emptyList()
nodes.isNotEmpty()
} catch (e: Exception) {
false
}
}
AsyncFunction("isReachable") {
try {
val nodes = nodeClient?.connectedNodes?.await() ?: emptyList()
nodes.any { it.isNearby }
} catch (e: Exception) {
false
}
}
AsyncFunction("sendWorkoutPlan") { plan: Map<String, Any?> ->
try {
val ctx = appContext.reactContext ?: return@AsyncFunction mapOf("error" to "No context")
val nodes = nodeClient?.connectedNodes?.await() ?: emptyList()
val nearbyNode = nodes.firstOrNull { it.isNearby }
?: return@AsyncFunction mapOf("error" to "No nearby watch found")
val json = JSONObject()
json.put("type", "workoutPlan")
for ((key, value) in plan) {
if (value != null) {
json.put(key, value)
}
}
messageClient?.sendMessage(
nearbyNode.id,
MESSAGE_PATH,
json.toString().toByteArray(Charsets.UTF_8)
)?.await()
mapOf("status" to "sent")
} catch (e: Exception) {
mapOf("error" to (e.message ?: "Failed to send"))
}
}
AsyncFunction("sendWorkoutCompletion") { data: Map<String, Any?> ->
try {
val nodes = nodeClient?.connectedNodes?.await() ?: emptyList()
val nearbyNode = nodes.firstOrNull { it.isNearby }
?: return@AsyncFunction mapOf("error" to "No nearby watch found")
val json = JSONObject()
json.put("type", "workoutCompleted")
for ((key, value) in data) {
if (value != null) {
json.put(key, value)
}
}
messageClient?.sendMessage(
nearbyNode.id,
MESSAGE_PATH,
json.toString().toByteArray(Charsets.UTF_8)
)?.await()
mapOf("status" to "sent")
} catch (e: Exception) {
mapOf("error" to (e.message ?: "Failed to send"))
}
}
AsyncFunction("pollEvents") {
synchronized(pendingEvents) {
val copy = ArrayList(pendingEvents)
pendingEvents.clear()
copy
}
}
}
fun handleIncomingMessage(messageEvent: MessageEvent) {
if (messageEvent.path != MESSAGE_PATH) return
try {
val json = String(messageEvent.data, Charsets.UTF_8)
val obj = JSONObject(json)
val map = mutableMapOf<String, Any?>()
for (key in obj.keys()) {
map[key] = if (obj.isNull(key)) null else obj.get(key)
}
synchronized(pendingEvents) {
pendingEvents.add(map)
}
} catch (e: Exception) {
// ignore malformed messages
}
}
override fun onDestroy() {
instance = null
}
}
@@ -0,0 +1,11 @@
package nl.guidoit.swimbuddy.wear
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
class WearMessageListenerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
WearConnectivityModule.instance?.handleIncomingMessage(messageEvent)
}
}
+40
View File
@@ -0,0 +1,40 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'expo-modules-core'
group = 'nl.guidoit.swimbuddy'
version = '0.1.0'
def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
android {
namespace "nl.guidoit.swimbuddy.wear"
compileSdkVersion safeExtGet("compileSdkVersion", 34)
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 24)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
repositories {
mavenCentral()
google()
}
dependencies {
implementation "expo-modules-core:+"
implementation "com.google.android.gms:play-services-wearable:18.2.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3"
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["nl.guidoit.swimbuddy.wear.WearConnectivityModule"]
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "wear-connectivity",
"version": "0.1.0"
}
+2
View File
@@ -0,0 +1,2 @@
-keep class nl.guidoit.swimbuddy.wear.** { *; }
-keep class com.google.android.gms.wearable.** { *; }