feat(watch): Android Wear Data Layer adapter + foreground service (#91-D)
This commit is contained in:
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
5
android/app/src/main/res/values/wear.xml
Normal file
5
android/app/src/main/res/values/wear.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string-array name="android_wear_capabilities">
|
||||
<item>gametime_phone_companion</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user