feat(watch): Android Wear Data Layer adapter + foreground service (#91-D)
This commit is contained in:
@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
@ -40,3 +41,7 @@ kotlin {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<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
|
<application
|
||||||
android:label="GameTime"
|
android:label="GameTime"
|
||||||
@ -33,6 +37,22 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
package com.gametime.app
|
package com.gametime.app
|
||||||
|
|
||||||
|
import com.gametime.app.watch.WatchBridgePlugin
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
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>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import '../infrastructure/local/local.dart';
|
import '../infrastructure/local/local.dart';
|
||||||
import '../infrastructure/remote/remote.dart';
|
import '../infrastructure/remote/remote.dart';
|
||||||
import '../infrastructure/security/security.dart';
|
import '../infrastructure/security/security.dart';
|
||||||
|
import '../infrastructure/watch_bridge/watch_bridge.dart';
|
||||||
import 'application.dart';
|
import 'application.dart';
|
||||||
|
|
||||||
abstract interface class AppDependencies {
|
abstract interface class AppDependencies {
|
||||||
@ -33,6 +34,7 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
required this.activeExerciseStepUseCases,
|
required this.activeExerciseStepUseCases,
|
||||||
required this.watchCompanionProjectionUseCases,
|
required this.watchCompanionProjectionUseCases,
|
||||||
required this.watchCompanionCommandHandler,
|
required this.watchCompanionCommandHandler,
|
||||||
|
required this.watchWearDataLayerAdapter,
|
||||||
required this.closeWorkoutSessionUseCase,
|
required this.closeWorkoutSessionUseCase,
|
||||||
required this.workoutHistoryUseCases,
|
required this.workoutHistoryUseCases,
|
||||||
required this.progressionStatsUseCase,
|
required this.progressionStatsUseCase,
|
||||||
@ -61,6 +63,7 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
|
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
|
||||||
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
|
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
|
||||||
final WatchCompanionCommandHandler watchCompanionCommandHandler;
|
final WatchCompanionCommandHandler watchCompanionCommandHandler;
|
||||||
|
final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
|
||||||
@override
|
@override
|
||||||
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
||||||
@override
|
@override
|
||||||
@ -126,6 +129,18 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
ids: ids,
|
ids: ids,
|
||||||
originDeviceId: originDeviceId,
|
originDeviceId: originDeviceId,
|
||||||
);
|
);
|
||||||
|
final watchCompanionCommandHandler = WatchCompanionCommandHandler(
|
||||||
|
sessionRepository: activeSessionRepository,
|
||||||
|
activeSessionUseCases: activeWorkoutSessionUseCases,
|
||||||
|
stepUseCases: activeExerciseStepUseCases,
|
||||||
|
projectionSource: watchCompanionProjectionUseCases,
|
||||||
|
);
|
||||||
|
final watchWearDataLayerAdapter = WatchWearDataLayerAdapter(
|
||||||
|
nativeChannel: const MethodChannelWatchBridgeNativeChannel(),
|
||||||
|
commandIngress: watchCompanionCommandHandler,
|
||||||
|
projectionSource: watchCompanionProjectionUseCases,
|
||||||
|
);
|
||||||
|
await watchWearDataLayerAdapter.start();
|
||||||
await SeedStarterContentUseCase(
|
await SeedStarterContentUseCase(
|
||||||
seedStateRepository: starterSeedRepository,
|
seedStateRepository: starterSeedRepository,
|
||||||
contentRepository: starterSeedRepository,
|
contentRepository: starterSeedRepository,
|
||||||
@ -184,12 +199,8 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
|
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
|
||||||
activeExerciseStepUseCases: activeExerciseStepUseCases,
|
activeExerciseStepUseCases: activeExerciseStepUseCases,
|
||||||
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
|
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
|
||||||
watchCompanionCommandHandler: WatchCompanionCommandHandler(
|
watchCompanionCommandHandler: watchCompanionCommandHandler,
|
||||||
sessionRepository: activeSessionRepository,
|
watchWearDataLayerAdapter: watchWearDataLayerAdapter,
|
||||||
activeSessionUseCases: activeWorkoutSessionUseCases,
|
|
||||||
stepUseCases: activeExerciseStepUseCases,
|
|
||||||
projectionSource: watchCompanionProjectionUseCases,
|
|
||||||
),
|
|
||||||
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
|
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
|
||||||
sessionRepository: activeSessionRepository,
|
sessionRepository: activeSessionRepository,
|
||||||
historyRepository: historyRepository,
|
historyRepository: historyRepository,
|
||||||
@ -243,5 +254,9 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() => database.close();
|
Future<void> dispose() async {
|
||||||
|
await watchWearDataLayerAdapter.stop();
|
||||||
|
await watchCompanionProjectionUseCases.dispose();
|
||||||
|
await database.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,3 +7,4 @@ library;
|
|||||||
export 'local/local.dart';
|
export 'local/local.dart';
|
||||||
export 'remote/remote.dart';
|
export 'remote/remote.dart';
|
||||||
export 'security/security.dart';
|
export 'security/security.dart';
|
||||||
|
export 'watch_bridge/watch_bridge.dart';
|
||||||
|
|||||||
137
lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart
Normal file
137
lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||||
|
|
||||||
|
final class WatchBridgeConnectionEvent {
|
||||||
|
const WatchBridgeConnectionEvent({
|
||||||
|
required this.isReachable,
|
||||||
|
this.requestsResync = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isReachable;
|
||||||
|
final bool requestsResync;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract interface class WatchBridgeNativeChannel {
|
||||||
|
Stream<WatchCommandEnvelope> get commands;
|
||||||
|
|
||||||
|
Stream<WatchBridgeConnectionEvent> get connectionEvents;
|
||||||
|
|
||||||
|
Future<void> publishProjection(WatchSessionProjection projection);
|
||||||
|
|
||||||
|
Future<void> sendCommandAck(
|
||||||
|
WatchCommandEnvelope command,
|
||||||
|
WatchCommandAck ack, {
|
||||||
|
int? revisionAtAck,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> requestCapabilityRefresh();
|
||||||
|
|
||||||
|
Future<void> startForegroundService();
|
||||||
|
|
||||||
|
Future<void> stopForegroundService();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class MethodChannelWatchBridgeNativeChannel
|
||||||
|
implements WatchBridgeNativeChannel {
|
||||||
|
const MethodChannelWatchBridgeNativeChannel({
|
||||||
|
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
|
||||||
|
EventChannel commandChannel = const EventChannel(_commandChannelName),
|
||||||
|
EventChannel connectionChannel = const EventChannel(_connectionChannelName),
|
||||||
|
}) : _methodChannel = methodChannel,
|
||||||
|
_commandChannel = commandChannel,
|
||||||
|
_connectionChannel = connectionChannel;
|
||||||
|
|
||||||
|
static const _methodChannelName = 'gametime.watch_bridge/methods';
|
||||||
|
static const _commandChannelName = 'gametime.watch_bridge/commands';
|
||||||
|
static const _connectionChannelName = 'gametime.watch_bridge/connection';
|
||||||
|
|
||||||
|
final MethodChannel _methodChannel;
|
||||||
|
final EventChannel _commandChannel;
|
||||||
|
final EventChannel _connectionChannel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<WatchCommandEnvelope> get commands {
|
||||||
|
return _commandChannel
|
||||||
|
.receiveBroadcastStream()
|
||||||
|
.where((event) {
|
||||||
|
return event is Map;
|
||||||
|
})
|
||||||
|
.map((event) {
|
||||||
|
return WatchCommandEnvelope.fromJson(_stringObjectMap(event));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<WatchBridgeConnectionEvent> get connectionEvents {
|
||||||
|
return _connectionChannel
|
||||||
|
.receiveBroadcastStream()
|
||||||
|
.where((event) {
|
||||||
|
return event is Map;
|
||||||
|
})
|
||||||
|
.map((event) {
|
||||||
|
final json = _stringObjectMap(event);
|
||||||
|
return WatchBridgeConnectionEvent(
|
||||||
|
isReachable: json['isReachable'] == true,
|
||||||
|
requestsResync: json['requestsResync'] == true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> publishProjection(WatchSessionProjection projection) {
|
||||||
|
return _invokeIgnoringMissingPlugin(
|
||||||
|
'publishProjection',
|
||||||
|
projection.toJson(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> requestCapabilityRefresh() {
|
||||||
|
return _invokeIgnoringMissingPlugin('requestCapabilityRefresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> sendCommandAck(
|
||||||
|
WatchCommandEnvelope command,
|
||||||
|
WatchCommandAck ack, {
|
||||||
|
int? revisionAtAck,
|
||||||
|
}) {
|
||||||
|
return _invokeIgnoringMissingPlugin('sendCommandAck', {
|
||||||
|
'schemaVersion': watchBridgeSchemaVersion,
|
||||||
|
'commandId': command.commandId,
|
||||||
|
'sessionId': command.sessionId,
|
||||||
|
'expectedRevision': command.expectedRevision,
|
||||||
|
'status': ack.name,
|
||||||
|
'revisionAtAck': revisionAtAck,
|
||||||
|
'ackedAtEpochMs': DateTime.now().toUtc().millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> startForegroundService() {
|
||||||
|
return _invokeIgnoringMissingPlugin('startForegroundService');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stopForegroundService() {
|
||||||
|
return _invokeIgnoringMissingPlugin('stopForegroundService');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _invokeIgnoringMissingPlugin(
|
||||||
|
String method, [
|
||||||
|
Object? arguments,
|
||||||
|
]) {
|
||||||
|
return _methodChannel
|
||||||
|
.invokeMethod<void>(method, arguments)
|
||||||
|
.onError<MissingPluginException>((_, _) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _stringObjectMap(Object? value) {
|
||||||
|
if (value is Map) {
|
||||||
|
return value.map((key, value) => MapEntry(key.toString(), value));
|
||||||
|
}
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
2
lib/infrastructure/watch_bridge/watch_bridge.dart
Normal file
2
lib/infrastructure/watch_bridge/watch_bridge.dart
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export 'native_watch_bridge_channel.dart';
|
||||||
|
export 'wear_data_layer_adapter.dart';
|
||||||
183
lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart
Normal file
183
lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||||
|
|
||||||
|
import '../../application/watch_companion_use_cases.dart';
|
||||||
|
import 'native_watch_bridge_channel.dart';
|
||||||
|
|
||||||
|
final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||||
|
WatchWearDataLayerAdapter({
|
||||||
|
required WatchBridgeNativeChannel nativeChannel,
|
||||||
|
required WatchCommandIngress commandIngress,
|
||||||
|
required WatchProjectionSource projectionSource,
|
||||||
|
Duration heartbeatInterval = const Duration(seconds: 5),
|
||||||
|
}) : _nativeChannel = nativeChannel,
|
||||||
|
_commandIngress = commandIngress,
|
||||||
|
_projectionSource = projectionSource,
|
||||||
|
_heartbeatInterval = heartbeatInterval;
|
||||||
|
|
||||||
|
final WatchBridgeNativeChannel _nativeChannel;
|
||||||
|
final WatchCommandIngress _commandIngress;
|
||||||
|
final WatchProjectionSource _projectionSource;
|
||||||
|
final Duration _heartbeatInterval;
|
||||||
|
final _commandAcks = <_WatchAdapterCommandKey, WatchCommandAck>{};
|
||||||
|
final _subscriptions = <StreamSubscription<dynamic>>[];
|
||||||
|
Future<void> _commandTail = Future<void>.value();
|
||||||
|
Timer? _heartbeatTimer;
|
||||||
|
WatchSessionProjection? _latestProjection;
|
||||||
|
bool _started = false;
|
||||||
|
bool _foregroundActive = false;
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
if (_started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_started = true;
|
||||||
|
_subscriptions.add(
|
||||||
|
_projectionSource.projections.listen((projection) {
|
||||||
|
unawaited(publish(projection));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
_subscriptions.add(
|
||||||
|
_nativeChannel.commands.listen((command) {
|
||||||
|
unawaited(_enqueueCommand(command));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
_subscriptions.add(
|
||||||
|
_nativeChannel.connectionEvents.listen((event) {
|
||||||
|
if (event.isReachable || event.requestsResync) {
|
||||||
|
unawaited(_projectionSource.emitCurrentProjection());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await _projectionSource.emitCurrentProjection();
|
||||||
|
await _nativeChannel.requestCapabilityRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
_heartbeatTimer?.cancel();
|
||||||
|
_heartbeatTimer = null;
|
||||||
|
for (final subscription in _subscriptions) {
|
||||||
|
await subscription.cancel();
|
||||||
|
}
|
||||||
|
_subscriptions.clear();
|
||||||
|
_started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> publish(WatchSessionProjection projection) async {
|
||||||
|
_latestProjection = projection;
|
||||||
|
await _nativeChannel.publishProjection(projection);
|
||||||
|
await _syncForegroundService(projection);
|
||||||
|
_syncHeartbeat(projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enqueueCommand(WatchCommandEnvelope command) {
|
||||||
|
final run = _commandTail.then(
|
||||||
|
(_) => _handleCommand(command),
|
||||||
|
onError: (_) => _handleCommand(command),
|
||||||
|
);
|
||||||
|
_commandTail = run.then((_) {}, onError: (_) {});
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleCommand(WatchCommandEnvelope command) async {
|
||||||
|
final key = _WatchAdapterCommandKey(command);
|
||||||
|
final cachedAck = _commandAcks[key];
|
||||||
|
if (cachedAck != null) {
|
||||||
|
await _sendAck(command, WatchCommandAck.acceptedNoOp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ack = await _commandIngress.dispatch(command);
|
||||||
|
if (ack == WatchCommandAck.accepted ||
|
||||||
|
ack == WatchCommandAck.acceptedNoOp) {
|
||||||
|
_rememberAck(key, ack);
|
||||||
|
}
|
||||||
|
await _sendAck(command, ack);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendAck(
|
||||||
|
WatchCommandEnvelope command,
|
||||||
|
WatchCommandAck ack,
|
||||||
|
) async {
|
||||||
|
int? revisionAtAck;
|
||||||
|
try {
|
||||||
|
revisionAtAck = (await _projectionSource.currentProjection()).revision;
|
||||||
|
} on Exception {
|
||||||
|
revisionAtAck = _latestProjection?.revision;
|
||||||
|
}
|
||||||
|
await _nativeChannel.sendCommandAck(
|
||||||
|
command,
|
||||||
|
ack,
|
||||||
|
revisionAtAck: revisionAtAck,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rememberAck(_WatchAdapterCommandKey key, WatchCommandAck ack) {
|
||||||
|
_commandAcks[key] = ack;
|
||||||
|
if (_commandAcks.length <= 128) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_commandAcks.remove(_commandAcks.keys.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncForegroundService(WatchSessionProjection projection) async {
|
||||||
|
final shouldRun =
|
||||||
|
projection.phase != WatchSessionPhase.noActiveSession &&
|
||||||
|
projection.deviceSessionId.isNotEmpty;
|
||||||
|
if (shouldRun == _foregroundActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_foregroundActive = shouldRun;
|
||||||
|
if (shouldRun) {
|
||||||
|
await _nativeChannel.startForegroundService();
|
||||||
|
} else {
|
||||||
|
await _nativeChannel.stopForegroundService();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncHeartbeat(WatchSessionProjection projection) {
|
||||||
|
if (!_hasRunningTimer(projection)) {
|
||||||
|
_heartbeatTimer?.cancel();
|
||||||
|
_heartbeatTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_heartbeatTimer ??= Timer.periodic(_heartbeatInterval, (_) {
|
||||||
|
unawaited(_projectionSource.emitCurrentProjection());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasRunningTimer(WatchSessionProjection projection) {
|
||||||
|
final timers = [
|
||||||
|
if (projection.dominantTimer != null) projection.dominantTimer!,
|
||||||
|
...projection.secondaryTimers,
|
||||||
|
];
|
||||||
|
return timers.any((timer) => timer.runState == WatchTimerRunState.running);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _WatchAdapterCommandKey {
|
||||||
|
_WatchAdapterCommandKey(WatchCommandEnvelope command)
|
||||||
|
: sessionId = command.sessionId,
|
||||||
|
expectedRevision = command.expectedRevision,
|
||||||
|
commandId = command.commandId,
|
||||||
|
type = command.type;
|
||||||
|
|
||||||
|
final String sessionId;
|
||||||
|
final int expectedRevision;
|
||||||
|
final String commandId;
|
||||||
|
final WatchCommandType type;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
other is _WatchAdapterCommandKey &&
|
||||||
|
sessionId == other.sessionId &&
|
||||||
|
expectedRevision == other.expectedRevision &&
|
||||||
|
commandId == other.commandId &&
|
||||||
|
type == other.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(sessionId, expectedRevision, commandId, type);
|
||||||
|
}
|
||||||
@ -0,0 +1,319 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gametime/application/application.dart';
|
||||||
|
import 'package:gametime/infrastructure/infrastructure.dart';
|
||||||
|
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('publishes every projection revision from the source stream', () async {
|
||||||
|
final native = _FakeWatchBridgeNativeChannel();
|
||||||
|
final source = _FakeProjectionSource(_projection(revision: 0));
|
||||||
|
final adapter = _adapter(native: native, source: source);
|
||||||
|
await adapter.start();
|
||||||
|
native.published.clear();
|
||||||
|
|
||||||
|
source.emit(_projection(revision: 1));
|
||||||
|
source.emit(_projection(revision: 2));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(native.published.map((projection) => projection.revision), [1, 2]);
|
||||||
|
await adapter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heartbeats while a timer is running', () async {
|
||||||
|
final native = _FakeWatchBridgeNativeChannel();
|
||||||
|
final source = _FakeProjectionSource(_runningProjection(revision: 1));
|
||||||
|
final adapter = _adapter(
|
||||||
|
native: native,
|
||||||
|
source: source,
|
||||||
|
heartbeatInterval: const Duration(milliseconds: 10),
|
||||||
|
);
|
||||||
|
await adapter.start();
|
||||||
|
native.published.clear();
|
||||||
|
source.emitCount = 0;
|
||||||
|
|
||||||
|
await adapter.publish(_runningProjection(revision: 1));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 35));
|
||||||
|
|
||||||
|
expect(source.emitCount, greaterThanOrEqualTo(1));
|
||||||
|
expect(native.published.length, greaterThanOrEqualTo(2));
|
||||||
|
await adapter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispatches watch command and sends ack back to native layer', () async {
|
||||||
|
final native = _FakeWatchBridgeNativeChannel();
|
||||||
|
final ingress = _FakeCommandIngress();
|
||||||
|
final source = _FakeProjectionSource(_projection(revision: 0));
|
||||||
|
final adapter = _adapter(native: native, ingress: ingress, source: source);
|
||||||
|
await adapter.start();
|
||||||
|
|
||||||
|
native.emitCommand(_command(WatchCommandType.pauseSession));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(ingress.commands.single.type, WatchCommandType.pauseSession);
|
||||||
|
expect(native.acks.single.ack, WatchCommandAck.accepted);
|
||||||
|
expect(native.acks.single.revisionAtAck, 1);
|
||||||
|
await adapter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deduplicates retry before dispatching to ingress again', () async {
|
||||||
|
final native = _FakeWatchBridgeNativeChannel();
|
||||||
|
final ingress = _FakeCommandIngress();
|
||||||
|
final source = _FakeProjectionSource(_projection(revision: 0));
|
||||||
|
final adapter = _adapter(native: native, ingress: ingress, source: source);
|
||||||
|
await adapter.start();
|
||||||
|
final command = _command(WatchCommandType.skipCurrentSet);
|
||||||
|
|
||||||
|
native.emitCommand(command);
|
||||||
|
native.emitCommand(command);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(ingress.commands, hasLength(1));
|
||||||
|
expect(native.acks.map((ack) => ack.ack), [
|
||||||
|
WatchCommandAck.accepted,
|
||||||
|
WatchCommandAck.acceptedNoOp,
|
||||||
|
]);
|
||||||
|
await adapter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits a full resync when a watch node reconnects', () async {
|
||||||
|
final native = _FakeWatchBridgeNativeChannel();
|
||||||
|
final source = _FakeProjectionSource(_projection(revision: 3));
|
||||||
|
final adapter = _adapter(native: native, source: source);
|
||||||
|
await adapter.start();
|
||||||
|
native.published.clear();
|
||||||
|
source.emitCount = 0;
|
||||||
|
|
||||||
|
native.emitConnection(
|
||||||
|
const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(source.emitCount, 1);
|
||||||
|
expect(native.published.single.revision, 5);
|
||||||
|
await adapter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('processes commands sequentially in receive order', () async {
|
||||||
|
final native = _FakeWatchBridgeNativeChannel();
|
||||||
|
final ingress = _BlockingCommandIngress();
|
||||||
|
final source = _FakeProjectionSource(_projection(revision: 0));
|
||||||
|
final adapter = _adapter(native: native, ingress: ingress, source: source);
|
||||||
|
await adapter.start();
|
||||||
|
|
||||||
|
native.emitCommand(_command(WatchCommandType.skipCurrentStep, id: 'first'));
|
||||||
|
native.emitCommand(_command(WatchCommandType.skipCurrentSet, id: 'second'));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(ingress.started, ['first']);
|
||||||
|
ingress.completeNext();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(ingress.started, ['first', 'second']);
|
||||||
|
ingress.completeNext();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(native.acks.map((ack) => ack.command.commandId), [
|
||||||
|
'first',
|
||||||
|
'second',
|
||||||
|
]);
|
||||||
|
await adapter.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
WatchWearDataLayerAdapter _adapter({
|
||||||
|
required _FakeWatchBridgeNativeChannel native,
|
||||||
|
WatchCommandIngress? ingress,
|
||||||
|
required _FakeProjectionSource source,
|
||||||
|
Duration heartbeatInterval = const Duration(seconds: 5),
|
||||||
|
}) {
|
||||||
|
return WatchWearDataLayerAdapter(
|
||||||
|
nativeChannel: native,
|
||||||
|
commandIngress: ingress ?? _FakeCommandIngress(),
|
||||||
|
projectionSource: source,
|
||||||
|
heartbeatInterval: heartbeatInterval,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WatchCommandEnvelope _command(
|
||||||
|
WatchCommandType type, {
|
||||||
|
String id = 'command-1',
|
||||||
|
}) {
|
||||||
|
return WatchCommandEnvelope(
|
||||||
|
commandId: id,
|
||||||
|
type: type,
|
||||||
|
sessionId: 'session-1',
|
||||||
|
expectedRevision: 1,
|
||||||
|
sentAtEpochMs: _now.millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WatchSessionProjection _projection({required int revision}) {
|
||||||
|
return WatchSessionProjection(
|
||||||
|
deviceSessionId: 'session-1',
|
||||||
|
revision: revision,
|
||||||
|
projectedAtEpochMs: _now.millisecondsSinceEpoch,
|
||||||
|
phase: WatchSessionPhase.ready,
|
||||||
|
phoneReachable: true,
|
||||||
|
seriesIndex: 1,
|
||||||
|
seriesTotal: 2,
|
||||||
|
exerciseName: 'Squat',
|
||||||
|
primaryAction: WatchPrimaryAction.startCurrentExercise,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WatchSessionProjection _runningProjection({required int revision}) {
|
||||||
|
return WatchSessionProjection(
|
||||||
|
deviceSessionId: 'session-1',
|
||||||
|
revision: revision,
|
||||||
|
projectedAtEpochMs: _now.millisecondsSinceEpoch,
|
||||||
|
phase: WatchSessionPhase.running,
|
||||||
|
phoneReachable: true,
|
||||||
|
seriesIndex: 1,
|
||||||
|
seriesTotal: 2,
|
||||||
|
exerciseName: 'Squat',
|
||||||
|
primaryAction: WatchPrimaryAction.pauseSession,
|
||||||
|
dominantTimer: WatchTimerProjection(
|
||||||
|
kind: WatchTimerKind.step,
|
||||||
|
label: 'Chrono étape',
|
||||||
|
displayMode: WatchTimerDisplayMode.countdown,
|
||||||
|
runState: WatchTimerRunState.running,
|
||||||
|
referenceEpochMs: _now.millisecondsSinceEpoch,
|
||||||
|
accumulatedMs: 0,
|
||||||
|
startedAtEpochMs: _now.millisecondsSinceEpoch,
|
||||||
|
targetMs: 30000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _now = DateTime.utc(2026, 7, 25, 12);
|
||||||
|
|
||||||
|
final class _FakeProjectionSource implements WatchProjectionSource {
|
||||||
|
_FakeProjectionSource(this.current);
|
||||||
|
|
||||||
|
WatchSessionProjection current;
|
||||||
|
var emitCount = 0;
|
||||||
|
final _controller = StreamController<WatchSessionProjection>.broadcast();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<WatchSessionProjection> get projections => _controller.stream;
|
||||||
|
|
||||||
|
void emit(WatchSessionProjection projection) {
|
||||||
|
current = projection;
|
||||||
|
_controller.add(projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WatchSessionProjection> currentProjection() async => current;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WatchSessionProjection> emitCurrentProjection() async {
|
||||||
|
emitCount += 1;
|
||||||
|
current = WatchSessionProjection(
|
||||||
|
deviceSessionId: current.deviceSessionId,
|
||||||
|
revision: current.revision + 1,
|
||||||
|
projectedAtEpochMs: current.projectedAtEpochMs,
|
||||||
|
phase: current.phase,
|
||||||
|
phoneReachable: current.phoneReachable,
|
||||||
|
seriesIndex: current.seriesIndex,
|
||||||
|
seriesTotal: current.seriesTotal,
|
||||||
|
exerciseName: current.exerciseName,
|
||||||
|
dominantTimer: current.dominantTimer,
|
||||||
|
secondaryTimers: current.secondaryTimers,
|
||||||
|
primaryAction: current.primaryAction,
|
||||||
|
secondaryActions: current.secondaryActions,
|
||||||
|
);
|
||||||
|
_controller.add(current);
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeCommandIngress implements WatchCommandIngress {
|
||||||
|
final commands = <WatchCommandEnvelope>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WatchCommandAck> dispatch(WatchCommandEnvelope command) async {
|
||||||
|
commands.add(command);
|
||||||
|
return WatchCommandAck.accepted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _BlockingCommandIngress implements WatchCommandIngress {
|
||||||
|
final started = <String>[];
|
||||||
|
final _pending = <Completer<WatchCommandAck>>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WatchCommandAck> dispatch(WatchCommandEnvelope command) {
|
||||||
|
started.add(command.commandId);
|
||||||
|
final completer = Completer<WatchCommandAck>();
|
||||||
|
_pending.add(completer);
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
void completeNext() {
|
||||||
|
_pending.removeAt(0).complete(WatchCommandAck.accepted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
|
||||||
|
final published = <WatchSessionProjection>[];
|
||||||
|
final acks = <_SentAck>[];
|
||||||
|
final _commands = StreamController<WatchCommandEnvelope>.broadcast();
|
||||||
|
final _connections = StreamController<WatchBridgeConnectionEvent>.broadcast();
|
||||||
|
var capabilityRefreshCount = 0;
|
||||||
|
var foregroundStartCount = 0;
|
||||||
|
var foregroundStopCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<WatchCommandEnvelope> get commands => _commands.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
|
||||||
|
_connections.stream;
|
||||||
|
|
||||||
|
void emitCommand(WatchCommandEnvelope command) {
|
||||||
|
_commands.add(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitConnection(WatchBridgeConnectionEvent event) {
|
||||||
|
_connections.add(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> publishProjection(WatchSessionProjection projection) async {
|
||||||
|
published.add(projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> requestCapabilityRefresh() async {
|
||||||
|
capabilityRefreshCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> sendCommandAck(
|
||||||
|
WatchCommandEnvelope command,
|
||||||
|
WatchCommandAck ack, {
|
||||||
|
int? revisionAtAck,
|
||||||
|
}) async {
|
||||||
|
acks.add(_SentAck(command, ack, revisionAtAck));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> startForegroundService() async {
|
||||||
|
foregroundStartCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stopForegroundService() async {
|
||||||
|
foregroundStopCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _SentAck {
|
||||||
|
const _SentAck(this.command, this.ack, this.revisionAtAck);
|
||||||
|
|
||||||
|
final WatchCommandEnvelope command;
|
||||||
|
final WatchCommandAck ack;
|
||||||
|
final int? revisionAtAck;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user