feat(watch): Android Wear Data Layer adapter + foreground service (#91-D)

This commit is contained in:
2026-07-25 19:51:43 +02:00
parent 40a2d5eddc
commit 68a87d1658
13 changed files with 1037 additions and 8 deletions

View File

@ -1,5 +1,6 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
@ -40,3 +41,7 @@ kotlin {
flutter {
source = "../.."
}
dependencies {
implementation("com.google.android.gms:play-services-wearable:19.0.0")
}

View File

@ -1,5 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="GameTime"
@ -33,6 +37,22 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name=".watch.WatchCompanionForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync" />
<service
android:name=".watch.PhoneWatchBridgeListenerService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<action android:name="com.google.android.gms.wearable.CAPABILITY_CHANGED" />
<data
android:host="*"
android:pathPrefix="/gametime"
android:scheme="wear" />
</intent-filter>
</service>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@ -1,5 +1,12 @@
package com.gametime.app
import com.gametime.app.watch.WatchBridgePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
WatchBridgePlugin.register(flutterEngine, applicationContext)
}
}

View File

@ -0,0 +1,63 @@
package com.gametime.app.watch
import com.google.android.gms.wearable.CapabilityInfo
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.Wearable
import com.google.android.gms.wearable.WearableListenerService
import org.json.JSONObject
import java.nio.charset.StandardCharsets
class PhoneWatchBridgeListenerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path != WatchBridgePlugin.COMMAND_PATH) {
return
}
val payload = JSONObject(String(messageEvent.data, StandardCharsets.UTF_8))
val command = payload.toMap()
val delivered = WatchBridgePlugin.emitCommand(command, messageEvent.sourceNodeId)
if (!delivered) {
sendPhoneBusyAck(command, messageEvent.sourceNodeId)
}
}
override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
if (capabilityInfo.name != WatchBridgePlugin.WATCH_CAPABILITY) {
return
}
WatchBridgePlugin.emitConnection(
isReachable = capabilityInfo.nodes.isNotEmpty(),
requestsResync = capabilityInfo.nodes.isNotEmpty(),
)
}
override fun onCreate() {
super.onCreate()
WatchBridgePlugin.requestCapabilityRefresh(applicationContext)
}
private fun sendPhoneBusyAck(command: Map<String, Any?>, sourceNodeId: String) {
val ack = JSONObject(
mapOf(
"schemaVersion" to (command["schemaVersion"] ?: 1),
"commandId" to command["commandId"],
"sessionId" to command["sessionId"],
"expectedRevision" to command["expectedRevision"],
"status" to "rejectedPhoneBusy",
"ackedAtEpochMs" to System.currentTimeMillis(),
),
).toString().toByteArray(StandardCharsets.UTF_8)
Wearable.getMessageClient(this)
.sendMessage(sourceNodeId, WatchBridgePlugin.ACK_PATH, ack)
}
}
private fun JSONObject.toMap(): Map<String, Any?> {
val output = linkedMapOf<String, Any?>()
val keys = keys()
while (keys.hasNext()) {
val key = keys.next()
val value = get(key)
output[key] = if (value == JSONObject.NULL) null else value
}
return output
}

View File

@ -0,0 +1,214 @@
package com.gametime.app.watch
import android.content.Context
import android.content.Intent
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.PutDataMapRequest
import com.google.android.gms.wearable.Wearable
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.json.JSONObject
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentHashMap
object WatchBridgePlugin {
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
private const val COMMAND_CHANNEL = "gametime.watch_bridge/commands"
private const val CONNECTION_CHANNEL = "gametime.watch_bridge/connection"
const val COMMAND_PATH = "/gametime/watch/command"
const val ACK_PATH = "/gametime/phone/ack"
const val STATE_PATH = "/gametime/phone/projection"
const val WATCH_CAPABILITY = "gametime_watch_companion"
private val pendingCommandNodes = ConcurrentHashMap<String, String>()
private var appContext: Context? = null
private var commandSink: EventChannel.EventSink? = null
private var connectionSink: EventChannel.EventSink? = null
fun register(flutterEngine: FlutterEngine, context: Context) {
appContext = context.applicationContext
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.setMethodCallHandler(::handleMethodCall)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, COMMAND_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
commandSink = events
}
override fun onCancel(arguments: Any?) {
commandSink = null
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CONNECTION_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
connectionSink = events
requestCapabilityRefresh(context.applicationContext)
}
override fun onCancel(arguments: Any?) {
connectionSink = null
}
},
)
}
fun emitCommand(payload: Map<String, Any?>, sourceNodeId: String): Boolean {
val sink = commandSink ?: return false
val commandId = payload["commandId"] as? String
if (commandId != null) {
pendingCommandNodes[commandId] = sourceNodeId
}
sink.success(payload)
return true
}
fun emitConnection(isReachable: Boolean, requestsResync: Boolean) {
connectionSink?.success(
mapOf(
"isReachable" to isReachable,
"requestsResync" to requestsResync,
),
)
}
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
val context = appContext
if (context == null) {
result.error("watch_bridge_unavailable", "Application context unavailable.", null)
return
}
when (call.method) {
"publishProjection" -> publishProjection(context, call.arguments, result)
"sendCommandAck" -> sendCommandAck(context, call.arguments, result)
"requestCapabilityRefresh" -> {
requestCapabilityRefresh(context)
result.success(null)
}
"startForegroundService" -> {
startForegroundService(context)
result.success(null)
}
"stopForegroundService" -> {
context.stopService(Intent(context, WatchCompanionForegroundService::class.java))
result.success(null)
}
else -> result.notImplemented()
}
}
private fun publishProjection(
context: Context,
arguments: Any?,
result: MethodChannel.Result,
) {
val map = arguments as? Map<*, *>
if (map == null) {
result.error("invalid_projection", "Projection payload must be a map.", null)
return
}
val projectionJson = JSONObject(map).toString()
val request = PutDataMapRequest.create(STATE_PATH).apply {
dataMap.putString("projectionJson", projectionJson)
dataMap.putInt("schemaVersion", (map["schemaVersion"] as? Number)?.toInt() ?: 1)
dataMap.putInt("revision", (map["revision"] as? Number)?.toInt() ?: 0)
dataMap.putLong(
"projectedAtEpochMs",
(map["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L,
)
}.asPutDataRequest().setUrgent()
Wearable.getDataClient(context).putDataItem(request)
.addOnSuccessListener { result.success(null) }
.addOnFailureListener { error ->
result.error("publish_projection_failed", error.message, null)
}
}
private fun sendCommandAck(
context: Context,
arguments: Any?,
result: MethodChannel.Result,
) {
val map = arguments as? Map<*, *>
if (map == null) {
result.error("invalid_ack", "Ack payload must be a map.", null)
return
}
val commandId = map["commandId"] as? String
val targetNode = commandId?.let { pendingCommandNodes.remove(it) }
val payload = JSONObject(map).toString().toByteArray(StandardCharsets.UTF_8)
if (targetNode != null) {
sendMessage(context, targetNode, payload, result)
return
}
Wearable.getCapabilityClient(context)
.getCapability(WATCH_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
val nodes = capability.nodes.toList()
if (nodes.isEmpty()) {
result.success(null)
return@addOnSuccessListener
}
var remaining = nodes.size
var failed = false
for (node in nodes) {
Wearable.getMessageClient(context)
.sendMessage(node.id, ACK_PATH, payload)
.addOnSuccessListener {
remaining -= 1
if (remaining == 0 && !failed) result.success(null)
}
.addOnFailureListener { error ->
failed = true
result.error("send_ack_failed", error.message, null)
}
}
}
.addOnFailureListener { error ->
result.error("capability_lookup_failed", error.message, null)
}
}
private fun sendMessage(
context: Context,
nodeId: String,
payload: ByteArray,
result: MethodChannel.Result,
) {
Wearable.getMessageClient(context)
.sendMessage(nodeId, ACK_PATH, payload)
.addOnSuccessListener { result.success(null) }
.addOnFailureListener { error ->
result.error("send_ack_failed", error.message, null)
}
}
fun requestCapabilityRefresh(context: Context) {
Wearable.getCapabilityClient(context)
.getCapability(WATCH_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
emitConnection(
isReachable = capability.nodes.isNotEmpty(),
requestsResync = capability.nodes.isNotEmpty(),
)
}
.addOnFailureListener {
emitConnection(isReachable = false, requestsResync = false)
}
}
private fun startForegroundService(context: Context) {
val intent = Intent(context, WatchCompanionForegroundService::class.java)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}

View File

@ -0,0 +1,58 @@
package com.gametime.app.watch
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import com.gametime.app.R
class WatchCompanionForegroundService : Service() {
override fun onCreate() {
super.onCreate()
ensureNotificationChannel()
startForeground(NOTIFICATION_ID, notification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun notification(): Notification {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
return builder
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("GameTime")
.setContentText("Séance en cours")
.setOngoing(true)
.setCategory(Notification.CATEGORY_SERVICE)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
"Synchronisation montre",
NotificationManager.IMPORTANCE_LOW,
)
manager.createNotificationChannel(channel)
}
private companion object {
const val CHANNEL_ID = "gametime_watch_companion"
const val NOTIFICATION_ID = 91
}
}

View File

@ -0,0 +1,5 @@
<resources>
<string-array name="android_wear_capabilities">
<item>gametime_phone_companion</item>
</string-array>
</resources>