From c91ea7ca9ef4de3b201c65cb5593072737e76a1d Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Fri, 19 Jun 2026 09:58:29 +0200 Subject: [PATCH 1/5] feat: stream from a chosen pair via SpecificDeviceSelector (device pinning) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startStreamSession(deviceId) now binds the session to a specific paired pair — a WearableDevice.id from getDevices() — via the SDK's SpecificDeviceSelector, instead of always using the shared AutoDeviceSelector. Pass null to auto-select. - Dart: rename the positional deviceUUID -> deviceId on startStreamSession / stopStreamSession / capturePhoto (+ the wire key); positional, non-breaking. - The pin is state (pinnedDeviceId) applied at startStreamSession through a single pin-aware selector factory used at EVERY construction site, so the blind-rebuild recovery path can't silently drop it. - Requesting a different deviceId while a stream is active returns STREAM_ACTIVE (PlatformException) — stop first; same id returns the existing texture. - iOS: selector widened to `any DeviceSelector`; on a pin change tear down the session, rebuild the selector, relaunch the availability watchdog, restart both event handlers (force: true), bump lastSelectorRebuild. - Android: deferred nullable selector holder + accessor (never constructed before Wearables.initialize); NOT_INITIALIZED guard on startStreamSession; both handlers gained restartMonitoring(force) to rebind after a swap. - Example: tap a pair (or "Automatic") in the Paired-devices sheet to choose which one streams next. Selection is the sole source of truth (null = Automatic); the mock is pinned explicitly on pair and cleared on unpair only when the selection still points at it. - Tests: plugin wire (deviceId forwarding / STREAM_ACTIVE) + example provider selection policy. Docs: CHANGELOG 0.6.0, README, AGENTS, agent rules. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 8 +- CHANGELOG.md | 5 +- README.md | 15 +- agent/claude/rules/dat-flutter-conventions.md | 2 +- agent/claude/skills/camera-streaming.md | 2 +- agent/cursor/rules/dat-conventions.mdc | 2 +- agent/cursor/rules/dat-streaming.mdc | 6 +- .../ActiveDeviceStreamHandler.kt | 9 +- .../DeviceStateStreamHandler.kt | 7 +- .../MetaWearablesDatPlugin.kt | 89 +++++-- example/lib/providers/stream_provider.dart | 52 +++- .../screens/stream/paired_devices_sheet.dart | 243 +++++++++++++----- example/test/selection_policy_test.dart | 90 +++++++ .../ActiveDeviceStreamHandler.swift | 4 +- .../DeviceStateStreamHandler.swift | 4 +- .../MetaWearablesDatPlugin.swift | 58 ++++- lib/flutter_meta_wearables_dat.dart | 35 ++- lib/meta_wearables_dat_method_channel.dart | 18 +- ...meta_wearables_dat_platform_interface.dart | 6 +- test/start_stream_session_pinning_test.dart | 59 +++++ 20 files changed, 565 insertions(+), 149 deletions(-) create mode 100644 example/test/selection_policy_test.dart create mode 100644 test/start_stream_session_pinning_test.dart diff --git a/AGENTS.md b/AGENTS.md index f0afea0..a2c318b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,12 +105,12 @@ static Future restartActiveDeviceMonitoring() // No-op on iOS // Streaming static Future startStreamSession( - String? deviceUUID, { + String? deviceId, { // WearableDevice.id (getDevices) pins a pair; null = auto-select double fps = 30.0, StreamQuality streamQuality = StreamQuality.high, VideoCodec videoCodec = VideoCodec.raw, -}) // Returns textureId for Texture widget -static Future stopStreamSession(String? deviceUUID) +}) // Returns textureId. Throws PlatformException 'STREAM_ACTIVE' if a different device is already streaming +static Future stopStreamSession(String? deviceId) static Stream streamSessionStateStream() static Stream streamSessionErrorStream() static Stream videoStreamSizeStream() @@ -134,7 +134,7 @@ static Future openDATGlassesAppUpdate() // Photo capture static Future capturePhoto( - String? deviceUUID, { + String? deviceId, { PhotoCaptureFormat format = PhotoCaptureFormat.jpeg, }) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc6719..2264ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ ## 0.6.0 -* New `getDevices()` returning `List` — enumerate the paired glasses with their `name`, model (`WearableDeviceType`), link state, compatibility, and display capability. `isStreamingDevice` flags the pair the current stream is actually using; `isActive` flags the pair the shared auto-selector would bind a *new* stream to (the two differ when more than one pair is connected). Use it to tell two pairs of the same model apart and see which one you're streaming from. Read-only — *selecting* (pinning) a specific pair is planned for a follow-up. +* New `getDevices()` returning `List` — enumerate the paired glasses with their `name`, model (`WearableDeviceType`), link state, compatibility, and display capability. `isStreamingDevice` flags the pair the current stream is using; `isActive` flags the pair the shared auto-selector would bind a *new* stream to (the two differ when more than one pair is connected). Use it to tell two pairs of the same model apart. +* **Device pinning** — `startStreamSession(deviceId)` now streams from a *specific* pair: pass a `WearableDevice.id` from `getDevices()` to pin it (via the SDK's `SpecificDeviceSelector`), or `null` to auto-select. Throws a `PlatformException` with code `STREAM_ACTIVE` if a *different* device is already streaming — stop it first. The positional `deviceUUID` parameter on `startStreamSession`/`stopStreamSession`/`capturePhoto` is renamed to `deviceId` (positional, so existing call sites are unaffected). * New types: `WearableDevice`, `WearableDeviceType`, `WearableLinkState`, `WearableCompatibility`. Non-breaking, drop-in addition. -* Example app: a "Paired devices" sheet (devices button, top-right) lists every paired pair and marks the streaming / selected one. +* Example app: a "Paired devices" sheet (devices button, top-right) lists every paired pair, marks the streaming pair, and lets you tap a pair (or "Automatic") to choose which one streams next. ## 0.5.3 * iOS: Fix first-registration device discovery and a `startStreamSession` hang after a failed start; surface genuine session errors. diff --git a/README.md b/README.md index f3357d0..495d221 100644 --- a/README.md +++ b/README.md @@ -258,18 +258,19 @@ The plugin follows Meta's integration lifecycle as documented in the [Meta Weara ### 3. Session (After registration and permissions) - Once registered and permissions are granted, start a streaming session -- Call `MetaWearablesDat.startStreamSession(deviceUUID)` — returns a `textureId` +- Call `MetaWearablesDat.startStreamSession(deviceId)` — pass a `WearableDevice.id` from `getDevices()` to stream from a specific pair, or `null` to auto-select the active one. Returns a `textureId` - Render the live video feed using Flutter's `Texture` widget with the returned ID - Monitor session state via `MetaWearablesDat.streamSessionStateStream()` - Monitor errors via `MetaWearablesDat.streamSessionErrorStream()` +- (Optional) Pin which pair streams by passing its `WearableDevice.id`; switching to a *different* pair while one is streaming throws a `PlatformException` with code `STREAM_ACTIVE` — stop the current stream first - (Optional) Monitor device thermal level via `MetaWearablesDat.deviceStateStream()` to warn the user *before* a thermal error stops the stream - (Optional) On `datAppOnTheGlassesUpdateRequired`, call `MetaWearablesDat.openDATGlassesAppUpdate()` to drive a "tap to update" UI -- Call `MetaWearablesDat.stopStreamSession(deviceUUID)` to end the session +- Call `MetaWearablesDat.stopStreamSession(deviceId)` to end the session ```dart // Start streaming — returns a texture ID for zero-copy rendering final textureId = await MetaWearablesDat.startStreamSession( - deviceUUID, + deviceId, fps: 24, streamQuality: StreamQuality.low, videoCodec: VideoCodec.raw, // or VideoCodec.hvc1 (iOS only, supports background streaming) @@ -313,12 +314,12 @@ for (final d in devices) { // Capture a photo during streaming final photo = await MetaWearablesDat.capturePhoto( - deviceUUID, + deviceId, format: PhotoCaptureFormat.jpeg, // or PhotoCaptureFormat.heic ); // Stop streaming when done -await MetaWearablesDat.stopStreamSession(deviceUUID); +await MetaWearablesDat.stopStreamSession(deviceId); ``` Video frames are pushed directly from native (CVPixelBuffer on iOS, SurfaceTexture on Android) to the Flutter engine — no JPEG encoding, no byte copying, no Dart-side decoding. @@ -353,10 +354,10 @@ await MetaWearablesDat.enableBackgroundStreaming( ), ); -final textureId = await MetaWearablesDat.startStreamSession(deviceUUID); +final textureId = await MetaWearablesDat.startStreamSession(deviceId); // ...later, when you no longer need background execution: -await MetaWearablesDat.stopStreamSession(deviceUUID); +await MetaWearablesDat.stopStreamSession(deviceId); await MetaWearablesDat.disableBackgroundStreaming(); ``` diff --git a/agent/claude/rules/dat-flutter-conventions.md b/agent/claude/rules/dat-flutter-conventions.md index 7b1e89f..a23543c 100644 --- a/agent/claude/rules/dat-flutter-conventions.md +++ b/agent/claude/rules/dat-flutter-conventions.md @@ -5,7 +5,7 @@ - Always use `MetaWearablesDat` static methods — never instantiate the class. - Single import: `import 'package:flutter_meta_wearables_dat/flutter_meta_wearables_dat.dart';` - All DAT operations are `async` — always `await` them. -- Pass `null` as `deviceUUID` to use AutoDeviceSelector (recommended for real devices). Only pass a specific UUID for mock devices. +- `startStreamSession(deviceId)`: `null` auto-selects the active pair; pass a `WearableDevice.id` from `getDevices()` to pin a specific pair (real or mock). Switching pairs while streaming throws `STREAM_ACTIVE` — stop first, then start. ## Lifecycle order diff --git a/agent/claude/skills/camera-streaming.md b/agent/claude/skills/camera-streaming.md index e41719d..1c7036c 100644 --- a/agent/claude/skills/camera-streaming.md +++ b/agent/claude/skills/camera-streaming.md @@ -28,7 +28,7 @@ final errorSub = MetaWearablesDat.streamSessionErrorStream().listen((error) { // Start streaming — returns texture ID final textureId = await MetaWearablesDat.startStreamSession( - null, // null = AutoDeviceSelector (recommended for real devices) + null, // null = auto-select; or a WearableDevice.id from getDevices() to pin a pair fps: 24, streamQuality: StreamQuality.low, videoCodec: VideoCodec.raw, diff --git a/agent/cursor/rules/dat-conventions.mdc b/agent/cursor/rules/dat-conventions.mdc index 96ea1a1..0ef177c 100644 --- a/agent/cursor/rules/dat-conventions.mdc +++ b/agent/cursor/rules/dat-conventions.mdc @@ -20,7 +20,7 @@ description: Coding conventions for flutter_meta_wearables_dat — API patterns, ## Video - `startStreamSession()` returns `textureId` → render with `Texture(textureId: id)`. - Never decode frames manually for display. -- Pass `null` as deviceUUID for real devices (AutoDeviceSelector), specific UUID only for mock devices. +- `startStreamSession(deviceId)`: pass `null` to auto-select the active pair, or a `WearableDevice.id` from `getDevices()` to pin a specific pair (real or mock). Switching to a different device while one is streaming throws `STREAM_ACTIVE` — stop first. ## Deep links - Use `app_links` package. Forward every URI to `MetaWearablesDat.handleUrl(uri.toString())`. diff --git a/agent/cursor/rules/dat-streaming.mdc b/agent/cursor/rules/dat-streaming.mdc index d0161e3..3401375 100644 --- a/agent/cursor/rules/dat-streaming.mdc +++ b/agent/cursor/rules/dat-streaming.mdc @@ -8,7 +8,7 @@ globs: "**/*stream*.dart,**/*video*.dart,**/*camera*.dart,**/*texture*.dart" ## Start session ```dart final textureId = await MetaWearablesDat.startStreamSession( - null, // null = AutoDeviceSelector + null, // null = auto-select; or a WearableDevice.id (getDevices()) to pin a pair fps: 24, streamQuality: StreamQuality.low, videoCodec: VideoCodec.raw, @@ -16,6 +16,10 @@ final textureId = await MetaWearablesDat.startStreamSession( // Render: Texture(textureId: textureId) ``` +## Pinning a specific pair +- Pass a `WearableDevice.id` from `getDevices()` as the first arg to stream from that exact pair (uses `SpecificDeviceSelector`); `null` auto-selects. +- Calling `startStreamSession` for a *different* device while one is already streaming throws a `PlatformException` `STREAM_ACTIVE` — stop the current stream first. + ## Subscribe to state/errors BEFORE starting - `streamSessionStateStream()` — stopping(0), stopped(1), waitingForDevice(2), starting(3), streaming(4), paused(5) - `streamSessionErrorStream()` — error codes: `thermalCritical`, `thermalEmergency`, `peakPowerShutdown`, `batteryCritical`, `hingesClosed`, `permissionDenied`, `datAppOnTheGlassesUpdateRequired` (call `openDATGlassesAppUpdate()` to recover), `dwaUnavailable`, plus device-session variants (`deviceThermalCritical` etc.). diff --git a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.kt b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.kt index 11c8614..35e8a4f 100644 --- a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.kt +++ b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.kt @@ -56,17 +56,20 @@ internal class ActiveDeviceStreamHandler( * double-subscribing. Matches the official CameraAccess sample's one-shot `startMonitoring()` * pattern. */ - fun restartMonitoring() { + fun restartMonitoring(force: Boolean = false) { val sink = eventSink if (sink == null) { Log.d(TAG, "ActiveDeviceStream restartMonitoring — no sink yet, skipping") return } - if (job?.isActive == true) { + // `force` rebinds to a freshly-swapped selector even while a job is live + // (e.g. after a device-pin change). startCollecting() cancels the old job + // and re-reads the selector provider. + if (!force && job?.isActive == true) { Log.d(TAG, "ActiveDeviceStream restartMonitoring — already collecting, skipping") return } - Log.d(TAG, "ActiveDeviceStream restartMonitoring — starting collection") + Log.d(TAG, "ActiveDeviceStream restartMonitoring — starting collection (force=$force)") startCollecting(sink) } diff --git a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/DeviceStateStreamHandler.kt b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/DeviceStateStreamHandler.kt index 3c06207..9cb6b59 100644 --- a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/DeviceStateStreamHandler.kt +++ b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/DeviceStateStreamHandler.kt @@ -61,9 +61,12 @@ internal class DeviceStateStreamHandler( * Restart the active-device subscription. Called after BT permissions are * granted so we don't miss the first device emission. */ - fun restartMonitoring() { + fun restartMonitoring(force: Boolean = false) { val sink = eventSink ?: return - if (outerJob?.isActive == true) return + // `force` rebinds to a freshly-swapped selector even while the outer job + // is live (e.g. after a device-pin change); startCollecting() cancels and + // re-reads the selector provider. + if (!force && outerJob?.isActive == true) return if (!isInitialized()) return startCollecting(sink) } diff --git a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt index 72e91c4..b12f4e7 100644 --- a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt +++ b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt @@ -123,9 +123,31 @@ class MetaWearablesDatPlugin : // Single shared device selector — mirrors reference app's WearablesViewModel pattern. // One instance is shared across device monitoring and stream session creation. - // Lazy init: constructing an `AutoDeviceSelector` before `Wearables.initialize()` - // crashes the plugin class load, which silently drops method-channel registration. - private val deviceSelector: DeviceSelector by lazy { AutoDeviceSelector() } + // + // Built lazily via [deviceSelector] only AFTER `Wearables.initialize()`: + // constructing an `AutoDeviceSelector`/`SpecificDeviceSelector` before init + // crashes the plugin class load, which silently drops method-channel + // registration. Hence a nullable holder + accessor, not `by lazy` (whose + // initializer could still run pre-init from a handler provider) or an eager + // field. + private var deviceSelectorRef: DeviceSelector? = null + + // Identifier of the pinned device, or null for auto-select. Honored by + // [makeDeviceSelector] at every construction site so a rebuild can't + // silently drop the pin. + private var pinnedDeviceId: String? = null + + /** Shared selector, built on first use (post-init) and cached. */ + private fun deviceSelector(): DeviceSelector = + deviceSelectorRef ?: makeDeviceSelector().also { deviceSelectorRef = it } + + /** Builds the shared selector honoring [pinnedDeviceId]. Call only post-init. */ + private fun makeDeviceSelector(): DeviceSelector = + pinnedDeviceId?.let { + com.meta.wearable.dat.core.selectors.SpecificDeviceSelector( + com.meta.wearable.dat.core.types.DeviceIdentifier(it) + ) + } ?: AutoDeviceSelector() // Streaming state — the DAT SDK splits what was historically one // `StreamSession` into a `DeviceSession` (device lifecycle) and a `Stream` @@ -163,7 +185,7 @@ class MetaWearablesDatPlugin : ) activeDeviceStreamHandler = ActiveDeviceStreamHandler( - { deviceSelector }, + { deviceSelector() }, { btPermissionsGranted }, { ensureWearablesInitialized() }, ) @@ -220,7 +242,7 @@ class MetaWearablesDatPlugin : ) deviceStateStreamHandler = DeviceStateStreamHandler( - deviceSelectorProvider = { deviceSelector }, + deviceSelectorProvider = { deviceSelector() }, isInitialized = { btPermissionsGranted }, ) deviceStateChannel.setStreamHandler(deviceStateStreamHandler) @@ -636,7 +658,7 @@ class MetaWearablesDatPlugin : scope.launch { try { ensureWearablesInitialized() - val active = deviceSelector.activeDevice()?.identifier + val active = deviceSelector().activeDevice()?.identifier val streaming = stream?.state?.value == com.meta.wearable.dat.camera.types.StreamState.STREAMING @@ -933,26 +955,47 @@ class MetaWearablesDatPlugin : return } + // Guard: the SDK can't create a selector/session until initialized, and + // `ensureWearablesInitialized()` no-ops without BT permission. Bail with a + // defined error before any pin mutation / selector construction / texture. + if (!btPermissionsGranted) { + result.error( + "NOT_INITIALIZED", + "Bluetooth permissions not granted; call requestAndroidPermissions() first.", + null, + ) + return + } + val args = call.arguments as? Map<*, *> val fps = (args?.get("fps") as? Double) ?: 30.0 val streamQuality = parseStreamQuality(args?.get("streamQuality") as? String) val videoCodec = args?.get("videoCodec") as? String - val deviceUUID = args?.get("deviceUUID") as? String - val key = deviceUUID ?: "auto" + val deviceId = args?.get("deviceId") as? String + val key = deviceId ?: "auto" if (videoCodec != null && videoCodec != "raw") { Log.d(TAG, "videoCodec '$videoCodec' ignored on Android (only raw I420 supported)") } - // Fast path: a Stream is already attached to the current Session. + // If a Stream is already active: re-requesting the same selection returns + // the existing texture; a *different* device must stop first. if (stream != null) { - val entry = textureEntry - if (entry != null) { - result.success(entry.id()) + if (deviceId == pinnedDeviceId) { + val entry = textureEntry + if (entry != null) { + result.success(entry.id()) + } else { + result.error( + "TEXTURE_REGISTRATION_FAILED", + "No texture registered for session $key", + null, + ) + } } else { result.error( - "TEXTURE_REGISTRATION_FAILED", - "No texture registered for session $key", + "STREAM_ACTIVE", + "A stream is already active on another device. Stop it before switching devices.", null, ) } @@ -963,6 +1006,18 @@ class MetaWearablesDatPlugin : try { ensureWearablesInitialized() + // Apply a pin change (or clear) before creating the session: + // rebuild the shared selector and rebind every observer. The + // availability watchdog is relaunched by ensureSessionStarted() + // below (it re-reads deviceSelector()). + if (deviceId != pinnedDeviceId) { + pinnedDeviceId = deviceId + teardownSession() + deviceSelectorRef = makeDeviceSelector() + activeDeviceStreamHandler?.restartMonitoring(force = true) + deviceStateStreamHandler?.restartMonitoring(force = true) + } + sessionKey = key frameProcessor.configure(fps) @@ -1089,9 +1144,9 @@ class MetaWearablesDatPlugin : // Capture the auto-selector's current pick *before* createSession so a // later A→B switch can't misattribute which device this session bound. - val candidate = deviceSelector.activeDevice()?.identifier + val candidate = deviceSelector().activeDevice()?.identifier var created: DeviceSession? = null - Wearables.createSession(deviceSelector) + Wearables.createSession(deviceSelector()) .onSuccess { created = it sessionDeviceId = candidate @@ -1141,7 +1196,7 @@ class MetaWearablesDatPlugin : if (deviceAvailabilityJob != null) return deviceAvailabilityJob = scope.launch { - deviceSelector.activeDeviceFlow().collect { device -> + deviceSelector().activeDeviceFlow().collect { device -> if (device == null && session != null) { Log.d(TAG, "Active device lost — tearing down Session") teardownSession() diff --git a/example/lib/providers/stream_provider.dart b/example/lib/providers/stream_provider.dart index 30f763c..985a9ef 100644 --- a/example/lib/providers/stream_provider.dart +++ b/example/lib/providers/stream_provider.dart @@ -41,7 +41,17 @@ class StreamSessionProvider extends ChangeNotifier { bool _refreshingDevices = false; bool _pendingDevicesRefresh = false; + // Device the user picked to stream from (a WearableDevice.id), or null for + // Automatic. Sole source of truth — null is reserved exclusively for + // Automatic, so the mock is pinned explicitly (see [syncMockSelection]). + String? _selectedDeviceId; + // Tracks the mock's UUID across pair/unpair so the selection can be + // reconciled without clobbering a real-pair choice. + String? _lastMockUUID; + StreamSessionProvider(this.deviceProvider, this.mockDeviceProvider) { + _lastMockUUID = mockDeviceProvider.deviceUUID; + mockDeviceProvider.addListener(_onMockDeviceChanged); _initializeActiveDeviceMonitoring(); _initializeDeviceStateMonitoring(); } @@ -71,6 +81,41 @@ class StreamSessionProvider extends ChangeNotifier { /// Human-readable error from the last [refreshDevices] call, or null. String? get devicesError => _devicesError; + /// The pair the next [startStreamSession] pins to (a [WearableDevice.id]), + /// or `null` for Automatic (SDK auto-selects). + String? get selectedDeviceId => _selectedDeviceId; + + /// Selects the device to stream from on the next start. `null` = Automatic. + void selectDevice(String? id) { + if (_selectedDeviceId == id) return; + _selectedDeviceId = id; + notifyListeners(); + } + + void _onMockDeviceChanged() { + final current = mockDeviceProvider.deviceUUID; + if (current == _lastMockUUID) return; + syncMockSelection(mockId: current, previousMockId: _lastMockUUID); + _lastMockUUID = current; + } + + /// Reconciles the selection when the mock device is paired/unpaired. Pinning + /// the mock explicitly keeps targeting deterministic (`null` stays reserved + /// for Automatic); unpairing only clears the selection when it still points + /// at the mock, so a real-pair selection made afterward is preserved. + @visibleForTesting + void syncMockSelection({ + required String? mockId, + required String? previousMockId, + }) { + if (mockId == previousMockId) return; + if (mockId != null) { + selectDevice(mockId); + } else if (_selectedDeviceId == previousMockId) { + selectDevice(null); + } + } + /// Current thermal level of the active device, or `null` if no device is /// active or the SDK hasn't reported a level yet. Updated live via /// [MetaWearablesDat.deviceStateStream]. @@ -166,6 +211,7 @@ class StreamSessionProvider extends ChangeNotifier { @override void dispose() { + mockDeviceProvider.removeListener(_onMockDeviceChanged); _activeDeviceSubscription?.cancel(); _sessionStateSubscription?.cancel(); _sessionErrorSubscription?.cancel(); @@ -300,7 +346,7 @@ class StreamSessionProvider extends ChangeNotifier { // Start the stream session - deviceUUID is optional (uses AutoDeviceSelector if null). // Returns a texture ID for zero-copy rendering via the Flutter Texture widget. _textureId = await MetaWearablesDat.startStreamSession( - mockDeviceProvider.deviceUUID, + _selectedDeviceId, fps: _fps, streamQuality: _streamQuality, videoCodec: _videoCodec, @@ -318,7 +364,7 @@ class StreamSessionProvider extends ChangeNotifier { unawaited(HapticFeedback.mediumImpact()); try { - await MetaWearablesDat.stopStreamSession(mockDeviceProvider.deviceUUID); + await MetaWearablesDat.stopStreamSession(_selectedDeviceId); unawaited(_sessionStateSubscription?.cancel()); _sessionStateSubscription = null; unawaited(_sessionErrorSubscription?.cancel()); @@ -361,7 +407,7 @@ class StreamSessionProvider extends ChangeNotifier { Future capturePhoto() async { try { final photo = await MetaWearablesDat.capturePhoto( - mockDeviceProvider.deviceUUID, + _selectedDeviceId, ); return photo; } catch (e) { diff --git a/example/lib/screens/stream/paired_devices_sheet.dart b/example/lib/screens/stream/paired_devices_sheet.dart index 526941b..19261ea 100644 --- a/example/lib/screens/stream/paired_devices_sheet.dart +++ b/example/lib/screens/stream/paired_devices_sheet.dart @@ -101,98 +101,215 @@ class _PairedDevicesSheetState extends State { } return ListView.separated( padding: EdgeInsets.zero, - itemCount: provider.devices.length, + itemCount: provider.devices.length + 1, separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (context, index) => - _DeviceTile(device: provider.devices[index]), + itemBuilder: (context, index) { + if (index == 0) { + return _AutomaticTile( + selected: provider.selectedDeviceId == null, + onTap: () => provider.selectDevice(null), + ); + } + final device = provider.devices[index - 1]; + return _DeviceTile( + device: device, + isSelected: provider.selectedDeviceId == device.id, + isStreaming: provider.isStreaming, + onTap: () => provider.selectDevice(device.id), + ); + }, ); } } -class _DeviceTile extends StatelessWidget { - const _DeviceTile({required this.device}); +/// "Automatic" entry — clears the pin so the SDK auto-selects the active pair. +class _AutomaticTile extends StatelessWidget { + const _AutomaticTile({required this.selected, required this.onTap}); - final WearableDevice device; + final bool selected; + final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final onSurface = theme.colorScheme.onSurface; - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: onSurface.withOpacity(0.04), + final primary = theme.colorScheme.primary; + return Material( + color: selected ? primary.withOpacity(0.08) : onSurface.withOpacity(0.04), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected ? primary : Colors.transparent, + width: 1.5, + ), + ), + child: Row( children: [ + Icon( + Icons.auto_mode, + size: 20, + color: onSurface.withOpacity(0.7), + ), + const SizedBox(width: 10), Expanded( - child: Text( - _modelLabel(device.type), - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: onSurface.withOpacity(0.9), - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Automatic', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: onSurface.withOpacity(0.9), + ), + ), + const SizedBox(height: 2), + Text( + 'Let the SDK pick the active pair', + style: theme.textTheme.bodySmall?.copyWith( + color: onSurface.withOpacity(0.55), + ), + ), + ], ), ), - if (device.isStreamingDevice) - const _Badge(label: 'Streaming', color: Colors.green) - else if (device.isActive) - _Badge(label: 'Selected', color: theme.colorScheme.primary), + if (selected) Icon(Icons.check_circle, color: primary, size: 20), ], ), - const SizedBox(height: 2), - Text( - device.name, - style: theme.textTheme.bodySmall?.copyWith( - color: onSurface.withOpacity(0.55), + ), + ), + ); + } +} + +class _DeviceTile extends StatelessWidget { + const _DeviceTile({ + required this.device, + required this.isSelected, + required this.isStreaming, + required this.onTap, + }); + + final WearableDevice device; + final bool isSelected; + final bool isStreaming; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + final primary = theme.colorScheme.primary; + // The user picked this pair, but a different one is currently streaming. + final needsRestartToSwitch = + isSelected && isStreaming && !device.isStreamingDevice; + return Material( + color: isSelected + ? primary.withOpacity(0.08) + : onSurface.withOpacity(0.04), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? primary : Colors.transparent, + width: 1.5, ), ), - const SizedBox(height: 12), - Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _Badge( - label: _linkLabel(device.linkState), - color: device.linkState == WearableLinkState.connected - ? Colors.green - : onSurface.withOpacity(0.45), - outlined: true, + Row( + children: [ + Expanded( + child: Text( + _modelLabel(device.type), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: onSurface.withOpacity(0.9), + ), + ), + ), + if (device.isStreamingDevice) + const _Badge(label: 'Streaming', color: Colors.green) + else if (isSelected) + _Badge(label: 'Will stream', color: primary) + else if (device.isActive) + _Badge( + label: 'Auto-pick', + color: onSurface.withOpacity(0.5), + outlined: true, + ), + ], ), - if (device.supportsDisplay) ...[ - const SizedBox(width: 6), - _Badge( - label: 'Display', - color: theme.colorScheme.primary, - outlined: true, + const SizedBox(height: 2), + Text( + device.name, + style: theme.textTheme.bodySmall?.copyWith( + color: onSurface.withOpacity(0.55), ), - ], - ], - ), - const SizedBox(height: 10), - Text( - 'ID: ${device.id}', - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - fontSize: 11, - color: onSurface.withOpacity(0.4), - ), - ), - if (device.firmwareInfo != null && device.firmwareInfo!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - 'Firmware: ${device.firmwareInfo}', + ), + const SizedBox(height: 12), + Row( + children: [ + _Badge( + label: _linkLabel(device.linkState), + color: device.linkState == WearableLinkState.connected + ? Colors.green + : onSurface.withOpacity(0.45), + outlined: true, + ), + if (device.supportsDisplay) ...[ + const SizedBox(width: 6), + _Badge(label: 'Display', color: primary, outlined: true), + ], + ], + ), + const SizedBox(height: 10), + Text( + 'ID: ${device.id}', style: theme.textTheme.bodySmall?.copyWith( fontFamily: 'monospace', fontSize: 11, color: onSurface.withOpacity(0.4), ), ), - ), - ], + if (device.firmwareInfo != null && + device.firmwareInfo!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + 'Firmware: ${device.firmwareInfo}', + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 11, + color: onSurface.withOpacity(0.4), + ), + ), + ), + if (needsRestartToSwitch) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Stop streaming, then Start to switch to this pair.', + style: theme.textTheme.bodySmall?.copyWith( + color: primary, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), ), ); } diff --git a/example/test/selection_policy_test.dart b/example/test/selection_policy_test.dart new file mode 100644 index 0000000..56a2b4d --- /dev/null +++ b/example/test/selection_policy_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_meta_wearables_dat_example/providers/device_provider.dart'; +import 'package:flutter_meta_wearables_dat_example/providers/mock_device_provider.dart'; +import 'package:flutter_meta_wearables_dat_example/providers/stream_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Channels the providers touch on construction. Mocked to no-op so the +/// providers can be built without a real platform. +const _channels = [ + 'flutter_meta_wearables_dat', + 'flutter_meta_wearables_dat/active_device', + 'flutter_meta_wearables_dat/device_state', + 'flutter_meta_wearables_dat/registration_state', + 'flutter_meta_wearables_dat/stream_session_state', + 'flutter_meta_wearables_dat/stream_session_errors', + 'flutter_meta_wearables_dat/video_stream_size', + 'flutter_meta_wearables_dat/video_frames', + 'flutter_meta_wearables_dat_mock_device', +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + StreamSessionProvider build() => + StreamSessionProvider(DeviceProvider(), MockDeviceProvider()); + + setUp(() { + for (final ch in _channels) { + messenger.setMockMethodCallHandler(MethodChannel(ch), (_) async => null); + } + }); + + tearDown(() { + for (final ch in _channels) { + messenger.setMockMethodCallHandler(MethodChannel(ch), null); + } + }); + + test('selectDevice updates selection; null means Automatic', () { + final sp = build(); + expect(sp.selectedDeviceId, isNull, reason: 'defaults to Automatic'); + + sp.selectDevice('dev-A'); + expect(sp.selectedDeviceId, 'dev-A'); + + sp.selectDevice(null); + expect(sp.selectedDeviceId, isNull); + + sp.dispose(); + }); + + test('pairing a mock pins the mock id explicitly', () { + final sp = build() + ..syncMockSelection(mockId: 'mock-1', previousMockId: null); + expect(sp.selectedDeviceId, 'mock-1'); + sp.dispose(); + }); + + test('unpairing the mock preserves a real-pair selection', () { + final sp = build() + ..syncMockSelection(mockId: 'mock-1', previousMockId: null); + expect(sp.selectedDeviceId, 'mock-1'); + + // User then picks a real pair. + sp.selectDevice('real-B'); + expect(sp.selectedDeviceId, 'real-B'); + + // Unpair the mock — selection now points at real-B, so it must stay. + sp.syncMockSelection(mockId: null, previousMockId: 'mock-1'); + expect(sp.selectedDeviceId, 'real-B'); + + sp.dispose(); + }); + + test( + 'unpairing the mock clears selection only when it still points there', + () { + final sp = build() + ..syncMockSelection(mockId: 'mock-2', previousMockId: null); + expect(sp.selectedDeviceId, 'mock-2'); + + sp.syncMockSelection(mockId: null, previousMockId: 'mock-2'); + expect(sp.selectedDeviceId, isNull); + + sp.dispose(); + }, + ); +} diff --git a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.swift b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.swift index 0188229..46454df 100644 --- a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.swift +++ b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.swift @@ -8,7 +8,7 @@ import MWDATCore /// moment to discover the active device, which produced spurious "waiting for /// an active device" states when Dart subscribed late in the app lifecycle. class ActiveDeviceStreamHandler: NSObject, FlutterStreamHandler { - private let deviceSelectorProvider: @MainActor () -> AutoDeviceSelector + private let deviceSelectorProvider: @MainActor () -> any DeviceSelector private var activeDeviceTask: Task? private var eventSink: FlutterEventSink? // True while the `activeDeviceStream()` collection loop is running. The SDK @@ -21,7 +21,7 @@ class ActiveDeviceStreamHandler: NSObject, FlutterStreamHandler { // defer from clearing `isMonitoring` out from under the live run. private var monitoringGeneration = 0 - init(deviceSelectorProvider: @escaping @MainActor () -> AutoDeviceSelector) { + init(deviceSelectorProvider: @escaping @MainActor () -> any DeviceSelector) { self.deviceSelectorProvider = deviceSelectorProvider super.init() } diff --git a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/DeviceStateStreamHandler.swift b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/DeviceStateStreamHandler.swift index c59ebb5..afe1566 100644 --- a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/DeviceStateStreamHandler.swift +++ b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/DeviceStateStreamHandler.swift @@ -9,7 +9,7 @@ import MWDATCore /// an outer subscription to `activeDeviceStream()` and switches its inner /// subscription whenever the active device changes. class DeviceStateStreamHandler: NSObject, FlutterStreamHandler { - private let deviceSelectorProvider: @MainActor () -> AutoDeviceSelector + private let deviceSelectorProvider: @MainActor () -> any DeviceSelector private var outerTask: Task? private var eventSink: FlutterEventSink? // See `ActiveDeviceStreamHandler`: the outer `activeDeviceStream()` loop dies @@ -18,7 +18,7 @@ class DeviceStateStreamHandler: NSObject, FlutterStreamHandler { private var isMonitoring = false private var monitoringGeneration = 0 - init(deviceSelectorProvider: @escaping @MainActor () -> AutoDeviceSelector) { + init(deviceSelectorProvider: @escaping @MainActor () -> any DeviceSelector) { self.deviceSelectorProvider = deviceSelectorProvider super.init() } diff --git a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift index 529127c..568b1bd 100644 --- a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift +++ b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift @@ -8,12 +8,22 @@ import VideoToolbox public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { // Device session (DAT 0.7.0): created lazily on first startStreamSession - // call using the shared AutoDeviceSelector. Kept alive across stream - // start/stop so toggling streaming is fast. Torn down only when the - // underlying device disappears or when the plugin is disabled. - private lazy var deviceSelector: AutoDeviceSelector = { - AutoDeviceSelector(wearables: Wearables.shared) - }() + // call using the shared selector. Kept alive across stream start/stop so + // toggling streaming is fast. Torn down only when the underlying device + // disappears or when the plugin is disabled. + // + // Identifier of the pinned device, or nil for auto-select. Honored by + // `makeDeviceSelector()` at every construction site so a blind rebuild + // can't silently drop the pin. + private var pinnedDeviceId: DeviceIdentifier? + // Shared selector: `SpecificDeviceSelector` when pinned, else + // `AutoDeviceSelector`. Lazy so it isn't built on the init path. + private lazy var deviceSelector: any DeviceSelector = makeDeviceSelector() + /// Builds the shared selector honoring `pinnedDeviceId`. + private func makeDeviceSelector() -> any DeviceSelector { + pinnedDeviceId.map { SpecificDeviceSelector(device: $0) } + ?? AutoDeviceSelector(wearables: Wearables.shared) + } private var deviceSession: DeviceSession? private var deviceSessionStateTask: Task? private var deviceSessionErrorTask: Task? @@ -412,7 +422,7 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { // Any idle/stopped leftover session is bound to the old selector — drop // it so the next startStreamSession creates against the fresh one. await teardownDeviceSession() - deviceSelector = AutoDeviceSelector(wearables: Wearables.shared) + deviceSelector = makeDeviceSelector() lastSelectorRebuild = Date() startDeviceAvailabilityMonitoring() return true @@ -811,21 +821,41 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { let videoCodecStr = args["videoCodec"] as? String ?? "raw" let videoCodec: MWDATCamera.VideoCodec = (videoCodecStr == "hvc1") ? .hvc1 : .raw - // `deviceUUID` is ignored in 0.6.0 — the plugin uses a single shared - // AutoDeviceSelector. The argument is kept for backward compatibility. - _ = args["deviceUUID"] as? String + // `deviceId` (a `WearableDevice.id` from getDevices) pins streaming to a + // specific pair; nil auto-selects. Applied below by rebuilding the shared + // selector. + let requestedDeviceId = args["deviceId"] as? String Task { @MainActor in - // Return existing texture if stream is already active. + // If a stream is already active: re-requesting the same selection returns + // the existing texture; requesting a *different* device must stop first — + // don't silently keep streaming the old one. if streamSession != nil { - if let texId = textureId { - result(texId) + if requestedDeviceId == pinnedDeviceId { + if let texId = textureId { + result(texId) + } else { + result(FlutterError(code: "TEXTURE_ERROR", message: "Session exists but no texture registered", details: nil)) + } } else { - result(FlutterError(code: "TEXTURE_ERROR", message: "Session exists but no texture registered", details: nil)) + result(FlutterError(code: "STREAM_ACTIVE", message: "A stream is already active on another device. Stop it before switching devices.", details: nil)) } return } + // Apply a pin change (or clear) before creating the session: rebuild the + // shared selector and rebind every observer (internal watchdog + the two + // event-channel handlers) so none keep watching the old selector. + if requestedDeviceId != pinnedDeviceId { + pinnedDeviceId = requestedDeviceId + await teardownDeviceSession() + deviceSelector = makeDeviceSelector() + lastSelectorRebuild = Date() + startDeviceAvailabilityMonitoring() + activeDeviceHandler?.restartMonitoring(force: true) + deviceStateHandler?.restartMonitoring(force: true) + } + guard let registry = textureRegistry else { result(FlutterError(code: "TEXTURE_ERROR", message: "Texture registry not available", details: nil)) return diff --git a/lib/flutter_meta_wearables_dat.dart b/lib/flutter_meta_wearables_dat.dart index 0f18838..1618553 100644 --- a/lib/flutter_meta_wearables_dat.dart +++ b/lib/flutter_meta_wearables_dat.dart @@ -684,47 +684,54 @@ class MetaWearablesDat { return MetaWearablesDatPlatform.instance.disconnect(); } - /// Starts a stream session with the given device UUID, FPS, and stream quality. + /// Starts a stream session. /// - /// Returns a texture ID for rendering via the Flutter `Texture` widget. - /// Video frames are pushed directly from native to the GPU — no encoding, - /// no byte copying, no Dart-side decoding. + /// Pass a [WearableDevice.id] (from [getDevices]) as [deviceId] to stream + /// from that specific pair; pass `null` to let the SDK auto-select the + /// active device. Returns a texture ID for rendering via the Flutter + /// `Texture` widget — frames are pushed native→GPU (no encoding, no byte + /// copying, no Dart-side decoding). + /// + /// Throws a `PlatformException` with code `STREAM_ACTIVE` if a stream is + /// already running on a *different* device — stop it first, then start. static Future startStreamSession( - String? deviceUUID, { + String? deviceId, { double fps = 30.0, StreamQuality streamQuality = StreamQuality.high, VideoCodec videoCodec = VideoCodec.raw, }) { if (kDebugMode) { debugPrint( - '[MetaWearablesDAT] Starting stream session with device UUID: $deviceUUID, FPS: $fps, Stream quality: $streamQuality, Video codec: $videoCodec', + '[MetaWearablesDAT] Starting stream session with deviceId: $deviceId, FPS: $fps, Stream quality: $streamQuality, Video codec: $videoCodec', ); } return MetaWearablesDatPlatform.instance.startStreamSession( - deviceUUID, + deviceId, fps: fps, streamQuality: streamQuality, videoCodec: videoCodec, ); } - /// Stops a stream session with the given device UUID. - static Future stopStreamSession(String? deviceUUID) { - return MetaWearablesDatPlatform.instance.stopStreamSession(deviceUUID); + /// Stops the active stream session. [deviceId] is accepted for call symmetry + /// but isn't required — there is a single active session. + static Future stopStreamSession(String? deviceId) { + return MetaWearablesDatPlatform.instance.stopStreamSession(deviceId); } - /// Captures a photo from the active stream session. + /// Captures a photo from the active stream session. [deviceId] is accepted + /// for call symmetry; the capture targets the active session. static Future capturePhoto( - String? deviceUUID, { + String? deviceId, { PhotoCaptureFormat format = PhotoCaptureFormat.jpeg, }) { if (kDebugMode) { debugPrint( - '[MetaWearablesDAT] Capturing photo with device UUID: $deviceUUID, format: $format', + '[MetaWearablesDAT] Capturing photo with deviceId: $deviceId, format: $format', ); } return MetaWearablesDatPlatform.instance.capturePhoto( - deviceUUID, + deviceId, format: format, ); } diff --git a/lib/meta_wearables_dat_method_channel.dart b/lib/meta_wearables_dat_method_channel.dart index c0f305a..d692bc0 100644 --- a/lib/meta_wearables_dat_method_channel.dart +++ b/lib/meta_wearables_dat_method_channel.dart @@ -122,7 +122,7 @@ class MethodChannelMetaWearablesDat extends MetaWearablesDatPlatform { @override Future startStreamSession( - String? deviceUUID, { + String? deviceId, { double fps = 30.0, StreamQuality streamQuality = StreamQuality.high, VideoCodec videoCodec = VideoCodec.raw, @@ -132,8 +132,8 @@ class MethodChannelMetaWearablesDat extends MetaWearablesDatPlatform { 'streamQuality': streamQuality.value, 'videoCodec': videoCodec.value, }; - if (deviceUUID != null) { - args['deviceUUID'] = deviceUUID; + if (deviceId != null) { + args['deviceId'] = deviceId; } final textureId = await methodChannel.invokeMethod( 'startStreamSession', @@ -149,10 +149,10 @@ class MethodChannelMetaWearablesDat extends MetaWearablesDatPlatform { } @override - Future stopStreamSession(String? deviceUUID) async { + Future stopStreamSession(String? deviceId) async { final args = {}; - if (deviceUUID != null) { - args['deviceUUID'] = deviceUUID; + if (deviceId != null) { + args['deviceId'] = deviceId; } final ok = await methodChannel.invokeMethod( 'stopStreamSession', @@ -163,14 +163,14 @@ class MethodChannelMetaWearablesDat extends MetaWearablesDatPlatform { @override Future capturePhoto( - String? deviceUUID, { + String? deviceId, { PhotoCaptureFormat format = PhotoCaptureFormat.jpeg, }) async { final args = { 'format': format.value, }; - if (deviceUUID != null) { - args['deviceUUID'] = deviceUUID; + if (deviceId != null) { + args['deviceId'] = deviceId; } final result = await methodChannel.invokeMapMethod( 'capturePhoto', diff --git a/lib/meta_wearables_dat_platform_interface.dart b/lib/meta_wearables_dat_platform_interface.dart index 3485801..e07507b 100644 --- a/lib/meta_wearables_dat_platform_interface.dart +++ b/lib/meta_wearables_dat_platform_interface.dart @@ -60,7 +60,7 @@ abstract class MetaWearablesDatPlatform extends PlatformInterface { /// Starts a stream session. Returns a texture ID (int) for rendering /// via the Flutter `Texture` widget (zero-copy path). Future startStreamSession( - String? deviceUUID, { + String? deviceId, { double fps = 30.0, StreamQuality streamQuality = StreamQuality.high, VideoCodec videoCodec = VideoCodec.raw, @@ -68,12 +68,12 @@ abstract class MetaWearablesDatPlatform extends PlatformInterface { throw UnimplementedError('startStreamSession() has not been implemented.'); } - Future stopStreamSession(String? deviceUUID) { + Future stopStreamSession(String? deviceId) { throw UnimplementedError('stopStreamSession() has not been implemented.'); } Future capturePhoto( - String? deviceUUID, { + String? deviceId, { PhotoCaptureFormat format = PhotoCaptureFormat.jpeg, }) { throw UnimplementedError('capturePhoto() has not been implemented.'); diff --git a/test/start_stream_session_pinning_test.dart b/test/start_stream_session_pinning_test.dart new file mode 100644 index 0000000..3ce37bc --- /dev/null +++ b/test/start_stream_session_pinning_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_meta_wearables_dat/meta_wearables_dat_method_channel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('flutter_meta_wearables_dat'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final platform = MethodChannelMetaWearablesDat(); + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + + test( + 'startStreamSession forwards deviceId under the "deviceId" key', + () async { + Map? captured; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method != 'startStreamSession') return null; + captured = call.arguments as Map; + return 7; // textureId + }); + + final texId = await platform.startStreamSession('dev-B'); + + expect(texId, 7); + expect(captured?['deviceId'], 'dev-B'); + }, + ); + + test('startStreamSession omits deviceId when null (Automatic)', () async { + Map? captured; + messenger.setMockMethodCallHandler(channel, (call) async { + captured = call.arguments as Map; + return 1; + }); + + await platform.startStreamSession(null); + + expect(captured, isNotNull); + expect(captured!.containsKey('deviceId'), isFalse); + }); + + test('startStreamSession propagates a STREAM_ACTIVE PlatformException', () { + messenger.setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'STREAM_ACTIVE', message: 'busy'); + }); + + expect( + platform.startStreamSession('dev-B'), + throwsA( + isA().having((e) => e.code, 'code', 'STREAM_ACTIVE'), + ), + ); + }); +} From 8b99d628c75da4672ac044d4ee312b7aea29c4e2 Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Fri, 19 Jun 2026 11:12:05 +0200 Subject: [PATCH 2/5] fix: address device-pinning review (stale-stream guard, start serialization, iOS selector monitoring) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [P1] Conflict guard now checks the actual Stream state: a non-nil but STOPPED/STOPPING stream counts as inactive (torn down and recreated), so restarting the same device no longer returns a stale texture and switching no longer false-positives STREAM_ACTIVE. Both platforms. - [P1] Serialize concurrent starts: reject a start while another is in flight (isStartingSession / startInProgress). Android ensureSessionStarted also bails if the session reaches STOPPED instead of STARTED, so a session torn down mid-start can't hang the awaiter. - [P2] iOS pins via a filtered AutoDeviceSelector (filter: identifier == id) instead of SpecificDeviceSelector — the latter's iOS activeDeviceStream() emits once then completes, which would stop the availability watchdog from observing the pinned device disconnecting. The filtered AutoDeviceSelector keeps a continuous stream (emits nil on disconnect). iOS selector field + handler provider types reverted from `any DeviceSelector` to concrete AutoDeviceSelector. Android keeps SpecificDeviceSelector (its Device exposes no id to filter on). Verified: dart analyze + flutter test (root + example) green; flutter build apk + ios --no-codesign green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- .../MetaWearablesDatPlugin.kt | 78 ++++++++++++++----- .../ActiveDeviceStreamHandler.swift | 4 +- .../DeviceStateStreamHandler.swift | 4 +- .../MetaWearablesDatPlugin.swift | 61 +++++++++++---- 5 files changed, 109 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2264ba4..1bdf6eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## 0.6.0 * New `getDevices()` returning `List` — enumerate the paired glasses with their `name`, model (`WearableDeviceType`), link state, compatibility, and display capability. `isStreamingDevice` flags the pair the current stream is using; `isActive` flags the pair the shared auto-selector would bind a *new* stream to (the two differ when more than one pair is connected). Use it to tell two pairs of the same model apart. -* **Device pinning** — `startStreamSession(deviceId)` now streams from a *specific* pair: pass a `WearableDevice.id` from `getDevices()` to pin it (via the SDK's `SpecificDeviceSelector`), or `null` to auto-select. Throws a `PlatformException` with code `STREAM_ACTIVE` if a *different* device is already streaming — stop it first. The positional `deviceUUID` parameter on `startStreamSession`/`stopStreamSession`/`capturePhoto` is renamed to `deviceId` (positional, so existing call sites are unaffected). +* **Device pinning** — `startStreamSession(deviceId)` now streams from a *specific* pair: pass a `WearableDevice.id` from `getDevices()` to pin it (via the SDK's device selectors — `SpecificDeviceSelector` on Android, a filtered `AutoDeviceSelector` on iOS so disconnect detection keeps working), or `null` to auto-select. Throws a `PlatformException` with code `STREAM_ACTIVE` if a *different* device is already streaming — stop it first. The positional `deviceUUID` parameter on `startStreamSession`/`stopStreamSession`/`capturePhoto` is renamed to `deviceId` (positional, so existing call sites are unaffected). * New types: `WearableDevice`, `WearableDeviceType`, `WearableLinkState`, `WearableCompatibility`. Non-breaking, drop-in addition. * Example app: a "Paired devices" sheet (devices button, top-right) lists every paired pair, marks the streaming pair, and lets you tap a pair (or "Automatic") to choose which one streams next. diff --git a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt index b12f4e7..e32e4db 100644 --- a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt +++ b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt @@ -164,6 +164,10 @@ class MetaWearablesDatPlugin : // teardown. Combined with a live STREAMING check in `getDevices`, it tells // which device is actually streaming. @Volatile private var sessionDeviceId: String? = null + + // Guards concurrent startStreamSession calls: a second start that tore down + // the first's in-flight session would leave the first awaiting STARTED. + @Volatile private var startInProgress = false private var videoJob: Job? = null private var streamErrorJob: Job? = null private var deviceAvailabilityJob: Job? = null @@ -978,29 +982,57 @@ class MetaWearablesDatPlugin : Log.d(TAG, "videoCodec '$videoCodec' ignored on Android (only raw I420 supported)") } - // If a Stream is already active: re-requesting the same selection returns - // the existing texture; a *different* device must stop first. - if (stream != null) { - if (deviceId == pinnedDeviceId) { - val entry = textureEntry - if (entry != null) { - result.success(entry.id()) + // A non-nil `stream` only counts as active if it's not terminal: the SDK + // can stop a stream (hinges, thermal) without clearing our reference. + // Same selection → return the existing texture; a *different* device → + // caller must stop first; a stale (terminal) stream is torn down and + // recreated below. + val existingStream = stream + if (existingStream != null) { + val st = existingStream.state.value + val live = + st != com.meta.wearable.dat.camera.types.StreamState.STOPPED && + st != + com.meta.wearable.dat.camera.types.StreamState + .STOPPING && + st != com.meta.wearable.dat.camera.types.StreamState.CLOSED + if (live) { + if (deviceId == pinnedDeviceId) { + val entry = textureEntry + if (entry != null) { + result.success(entry.id()) + } else { + result.error( + "TEXTURE_REGISTRATION_FAILED", + "No texture registered for session $key", + null, + ) + } } else { result.error( - "TEXTURE_REGISTRATION_FAILED", - "No texture registered for session $key", + "STREAM_ACTIVE", + "A stream is already active on another device. Stop it before switching devices.", null, ) } - } else { - result.error( - "STREAM_ACTIVE", - "A stream is already active on another device. Stop it before switching devices.", - null, - ) + return } + // Stale (terminal) stream — drop it and recreate below. + teardownStreamOnly() + } + + // Reject a concurrent start: a second start that tore down the first's + // in-flight session (on a pin change) would leave the first awaiting + // STARTED forever. + if (startInProgress) { + result.error( + "STREAM_ACTIVE", + "A stream start is already in progress.", + null, + ) return } + startInProgress = true scope.launch { try { @@ -1124,6 +1156,8 @@ class MetaWearablesDatPlugin : Log.e(TAG, "Failed to start stream session", e) teardownStreamOnly() result.error("STREAM_ERROR", e.message ?: "Failed to start stream session.", null) + } finally { + startInProgress = false } } } @@ -1181,9 +1215,17 @@ class MetaWearablesDatPlugin : } } - // Wait until the session transitions to STARTED before we try to add - // a stream — mirrors the reference app pattern. - newSession.state.first { it == DeviceSessionState.STARTED } + // Wait until the session transitions to STARTED before we try to add a + // stream — mirrors the reference app pattern. Also bail on a terminal + // STOPPED so a session torn down underneath us can't hang the awaiter. + val reached = + newSession.state.first { + it == DeviceSessionState.STARTED || it == DeviceSessionState.STOPPED + } + if (reached != DeviceSessionState.STARTED) { + Log.w(TAG, "Session reached $reached before STARTED; aborting start") + return null + } // Subscribe to the active-device flow so we tear the Session down // when the device disappears. Safe to call multiple times. diff --git a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.swift b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.swift index 46454df..0188229 100644 --- a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.swift +++ b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/ActiveDeviceStreamHandler.swift @@ -8,7 +8,7 @@ import MWDATCore /// moment to discover the active device, which produced spurious "waiting for /// an active device" states when Dart subscribed late in the app lifecycle. class ActiveDeviceStreamHandler: NSObject, FlutterStreamHandler { - private let deviceSelectorProvider: @MainActor () -> any DeviceSelector + private let deviceSelectorProvider: @MainActor () -> AutoDeviceSelector private var activeDeviceTask: Task? private var eventSink: FlutterEventSink? // True while the `activeDeviceStream()` collection loop is running. The SDK @@ -21,7 +21,7 @@ class ActiveDeviceStreamHandler: NSObject, FlutterStreamHandler { // defer from clearing `isMonitoring` out from under the live run. private var monitoringGeneration = 0 - init(deviceSelectorProvider: @escaping @MainActor () -> any DeviceSelector) { + init(deviceSelectorProvider: @escaping @MainActor () -> AutoDeviceSelector) { self.deviceSelectorProvider = deviceSelectorProvider super.init() } diff --git a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/DeviceStateStreamHandler.swift b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/DeviceStateStreamHandler.swift index afe1566..c59ebb5 100644 --- a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/DeviceStateStreamHandler.swift +++ b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/DeviceStateStreamHandler.swift @@ -9,7 +9,7 @@ import MWDATCore /// an outer subscription to `activeDeviceStream()` and switches its inner /// subscription whenever the active device changes. class DeviceStateStreamHandler: NSObject, FlutterStreamHandler { - private let deviceSelectorProvider: @MainActor () -> any DeviceSelector + private let deviceSelectorProvider: @MainActor () -> AutoDeviceSelector private var outerTask: Task? private var eventSink: FlutterEventSink? // See `ActiveDeviceStreamHandler`: the outer `activeDeviceStream()` loop dies @@ -18,7 +18,7 @@ class DeviceStateStreamHandler: NSObject, FlutterStreamHandler { private var isMonitoring = false private var monitoringGeneration = 0 - init(deviceSelectorProvider: @escaping @MainActor () -> any DeviceSelector) { + init(deviceSelectorProvider: @escaping @MainActor () -> AutoDeviceSelector) { self.deviceSelectorProvider = deviceSelectorProvider super.init() } diff --git a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift index 568b1bd..d93ce33 100644 --- a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift +++ b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift @@ -16,14 +16,26 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { // `makeDeviceSelector()` at every construction site so a blind rebuild // can't silently drop the pin. private var pinnedDeviceId: DeviceIdentifier? - // Shared selector: `SpecificDeviceSelector` when pinned, else - // `AutoDeviceSelector`. Lazy so it isn't built on the init path. - private lazy var deviceSelector: any DeviceSelector = makeDeviceSelector() + // Shared selector. When pinned, an `AutoDeviceSelector` *constrained by a + // filter* to the chosen device — NOT a `SpecificDeviceSelector`, whose iOS + // `activeDeviceStream()` emits once then completes (which would stop the + // availability watchdog from ever observing the pinned device disconnect). + // A filtered `AutoDeviceSelector` keeps a continuous stream that emits nil + // when the pinned device drops. Lazy so it isn't built on the init path. + private lazy var deviceSelector: AutoDeviceSelector = makeDeviceSelector() /// Builds the shared selector honoring `pinnedDeviceId`. - private func makeDeviceSelector() -> any DeviceSelector { - pinnedDeviceId.map { SpecificDeviceSelector(device: $0) } - ?? AutoDeviceSelector(wearables: Wearables.shared) + private func makeDeviceSelector() -> AutoDeviceSelector { + guard let id = pinnedDeviceId else { + return AutoDeviceSelector(wearables: Wearables.shared) + } + return AutoDeviceSelector( + wearables: Wearables.shared, + filter: { $0.identifier == id } + ) } + // Guards concurrent startStreamSession calls: a second start that tore down + // the first's in-flight session would leave the first awaiting `.started`. + private var isStartingSession = false private var deviceSession: DeviceSession? private var deviceSessionStateTask: Task? private var deviceSessionErrorTask: Task? @@ -827,20 +839,35 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { let requestedDeviceId = args["deviceId"] as? String Task { @MainActor in - // If a stream is already active: re-requesting the same selection returns - // the existing texture; requesting a *different* device must stop first — - // don't silently keep streaming the old one. - if streamSession != nil { - if requestedDeviceId == pinnedDeviceId { - if let texId = textureId { - result(texId) + // Reject a concurrent start: a second start that tears down the first's + // in-flight session (on a pin change) would leave the first awaiting + // `.started` forever. + if isStartingSession { + result(FlutterError(code: "STREAM_ACTIVE", message: "A stream start is already in progress.", details: nil)) + return + } + isStartingSession = true + defer { isStartingSession = false } + + // A non-nil `streamSession` only counts as active if it's not terminal: + // the SDK can stop a stream (hinges, thermal) without clearing our + // reference, so check the actual state. Same selection → return the + // existing texture; a *different* device → caller must stop first. + if let existing = streamSession { + if existing.state != .stopped && existing.state != .stopping { + if requestedDeviceId == pinnedDeviceId { + if let texId = textureId { + result(texId) + } else { + result(FlutterError(code: "TEXTURE_ERROR", message: "Session exists but no texture registered", details: nil)) + } } else { - result(FlutterError(code: "TEXTURE_ERROR", message: "Session exists but no texture registered", details: nil)) + result(FlutterError(code: "STREAM_ACTIVE", message: "A stream is already active on another device. Stop it before switching devices.", details: nil)) } - } else { - result(FlutterError(code: "STREAM_ACTIVE", message: "A stream is already active on another device. Stop it before switching devices.", details: nil)) + return } - return + // Stale (stopped/stopping) stream — drop the reference and recreate. + await teardownStreamOnly() } // Apply a pin change (or clear) before creating the session: rebuild the From 5fd82498c06857b73ea3fbc8065d2255615533a1 Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Fri, 19 Jun 2026 12:03:22 +0200 Subject: [PATCH 3/5] fix: prevent pin deadlock when switching to a connected pair; correct selector doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [P1] Gate the example Start button on the *selected* pair's connectivity (new `canStartSelected`), not the shared selector's activeDevice. With a pin to a disconnected pair, `hasActiveDevice` stays false so Start was disabled — and since selecting a different (connected) pair only updates the Dart selection until the next startStreamSession, that switching call could never fire (deadlock). `canStartSelected` reflects the selected pair's link state from the last getDevices snapshot (falling back to hasActiveDevice in Automatic mode), so a connected pair can be picked to switch to — the native selector re-pins on start. Added a provider test. - [P3] dat-streaming.mdc no longer claims pinning always uses SpecificDeviceSelector; now notes SpecificDeviceSelector on Android and a filtered AutoDeviceSelector on iOS, matching the CHANGELOG. Co-Authored-By: Claude Opus 4.8 --- agent/cursor/rules/dat-streaming.mdc | 2 +- example/lib/providers/stream_provider.dart | 14 ++++++ example/lib/screens/stream/stream_screen.dart | 30 +++++++----- example/test/selection_policy_test.dart | 47 +++++++++++++++++++ 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/agent/cursor/rules/dat-streaming.mdc b/agent/cursor/rules/dat-streaming.mdc index 3401375..cad12ac 100644 --- a/agent/cursor/rules/dat-streaming.mdc +++ b/agent/cursor/rules/dat-streaming.mdc @@ -17,7 +17,7 @@ final textureId = await MetaWearablesDat.startStreamSession( ``` ## Pinning a specific pair -- Pass a `WearableDevice.id` from `getDevices()` as the first arg to stream from that exact pair (uses `SpecificDeviceSelector`); `null` auto-selects. +- Pass a `WearableDevice.id` from `getDevices()` as the first arg to stream from that exact pair (implemented via `SpecificDeviceSelector` on Android and a filtered `AutoDeviceSelector` on iOS); `null` auto-selects. - Calling `startStreamSession` for a *different* device while one is already streaming throws a `PlatformException` `STREAM_ACTIVE` — stop the current stream first. ## Subscribe to state/errors BEFORE starting diff --git a/example/lib/providers/stream_provider.dart b/example/lib/providers/stream_provider.dart index 985a9ef..57aa7ec 100644 --- a/example/lib/providers/stream_provider.dart +++ b/example/lib/providers/stream_provider.dart @@ -85,6 +85,20 @@ class StreamSessionProvider extends ChangeNotifier { /// or `null` for Automatic (SDK auto-selects). String? get selectedDeviceId => _selectedDeviceId; + /// Whether "Start" should be enabled for the current selection. In Automatic + /// mode (`selectedDeviceId == null`) this mirrors [hasActiveDevice]; with a + /// specific pair selected it reflects that pair's connectivity from the last + /// [refreshDevices] snapshot — so the user can switch to a connected pair + /// even while a previously pinned pair is gone (the native selector re-pins + /// on the next [startStreamSession], which is what makes that pair active). + bool get canStartSelected { + final id = _selectedDeviceId; + if (id == null) return _hasActiveDevice; + final match = _devices.where((d) => d.id == id); + if (match.isEmpty) return _hasActiveDevice; + return match.first.linkState == WearableLinkState.connected; + } + /// Selects the device to stream from on the next start. `null` = Automatic. void selectDevice(String? id) { if (_selectedDeviceId == id) return; diff --git a/example/lib/screens/stream/stream_screen.dart b/example/lib/screens/stream/stream_screen.dart index 5c78b50..248d41c 100644 --- a/example/lib/screens/stream/stream_screen.dart +++ b/example/lib/screens/stream/stream_screen.dart @@ -63,8 +63,8 @@ class _StreamScreenState extends State { error.isThermalCritical ? Icons.thermostat : isDatUpdate - ? Icons.system_update - : Icons.error_outline, + ? Icons.system_update + : Icons.error_outline, color: Colors.white, size: 20, ), @@ -78,8 +78,8 @@ class _StreamScreenState extends State { duration: isTransient ? const Duration(seconds: 3) : isDatUpdate - ? const Duration(seconds: 10) - : const Duration(seconds: 6), + ? const Duration(seconds: 10) + : const Duration(seconds: 6), action: isDatUpdate ? SnackBarAction( label: 'Update', @@ -101,8 +101,11 @@ class _StreamScreenState extends State { stream_providers.StreamSessionProvider >( builder: (context, deviceProvider, mockDeviceProvider, streamProvider, child) { - // Use the actual active device status from the DAT SDK - final hasActiveDevice = streamProvider.hasActiveDevice; + // Whether Start is enabled for the current selection: in Automatic mode + // this is the active-device status; with a pair pinned it's that pair's + // connectivity, so you can switch to a connected pair even if a + // previously pinned pair has gone away. + final canStart = streamProvider.canStartSelected; return Stack( children: [ @@ -180,8 +183,10 @@ class _StreamScreenState extends State { children: [ // Session state label if (streamProvider.sessionState != null && - streamProvider.sessionState != StreamSessionState.streaming && - streamProvider.sessionState != StreamSessionState.stopped) + streamProvider.sessionState != + StreamSessionState.streaming && + streamProvider.sessionState != + StreamSessionState.stopped) Padding( padding: const EdgeInsets.only(bottom: 5), child: Text( @@ -195,7 +200,7 @@ class _StreamScreenState extends State { // Show "Waiting for an active device" message when no device is available // Always render it but control visibility with opacity (like native sample app) Opacity( - opacity: hasActiveDevice ? 0.0 : 1.0, + opacity: canStart ? 0.0 : 1.0, child: Padding( padding: const EdgeInsets.only(bottom: 5), child: Row( @@ -222,11 +227,11 @@ class _StreamScreenState extends State { if (!streamProvider.isStreaming) MetaButton.text( text: 'Start streaming', - enabled: hasActiveDevice, + enabled: canStart, onPressed: () async { unawaited(HapticFeedback.mediumImpact()); - if (!hasActiveDevice) return; + if (!canStart) return; final hasPermission = await deviceProvider .ensureCameraPermission(); @@ -343,8 +348,7 @@ class _ThermalChip extends StatelessWidget { return switch (level) { ThermalLevel.unknown || ThermalLevel.none || - ThermalLevel.light => - Colors.green.shade700, + ThermalLevel.light => Colors.green.shade700, ThermalLevel.moderate => Colors.amber.shade800, ThermalLevel.severe => Colors.orange.shade800, ThermalLevel.critical => Colors.red.shade700, diff --git a/example/test/selection_policy_test.dart b/example/test/selection_policy_test.dart index 56a2b4d..35437b6 100644 --- a/example/test/selection_policy_test.dart +++ b/example/test/selection_policy_test.dart @@ -87,4 +87,51 @@ void main() { sp.dispose(); }, ); + + test('canStartSelected reflects the selected pair connectivity', () async { + messenger.setMockMethodCallHandler( + const MethodChannel('flutter_meta_wearables_dat'), + (call) async { + if (call.method == 'getDevices') { + return [ + { + 'id': 'A', + 'name': 'A', + 'deviceType': 'rayBanMeta', + 'linkState': 'connected', + 'compatibility': 'compatible', + 'supportsDisplay': false, + 'isActive': false, + 'isStreamingDevice': false, + }, + { + 'id': 'B', + 'name': 'B', + 'deviceType': 'rayBanMeta', + 'linkState': 'disconnected', + 'compatibility': 'compatible', + 'supportsDisplay': false, + 'isActive': false, + 'isStreamingDevice': false, + }, + ]; + } + return null; + }, + ); + + final sp = build(); + addTearDown(sp.dispose); + await sp.refreshDevices(); + + // No selection + no active device → Start disabled. + expect(sp.canStartSelected, isFalse); + // A connected pair selected → Start enabled even with no auto-active device + // (this is the deadlock fix: you can pick a connected pair to switch to). + sp.selectDevice('A'); + expect(sp.canStartSelected, isTrue); + // A disconnected pair selected → Start disabled. + sp.selectDevice('B'); + expect(sp.canStartSelected, isFalse); + }); } From 4e68af96c4d21e81ed2357fc5e7bb3b79d7f4e39 Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Fri, 19 Jun 2026 17:59:08 +0200 Subject: [PATCH 4/5] fix: await rebuilt selector resolving the pinned device before createSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a device-pin change the shared selector is rebuilt, and a freshly-built selector resolves its active device asynchronously — so createSession ran against an unresolved selector and failed with noEligibleDevice. (Seen on hardware: the pinned device's `activeDevice -> true` arrived a beat *after* the failure, confirming the device was found, just too late.) startStreamSession now waits — after a pin change to a specific device — for the selector's activeDevice to resolve (up to ~8s) before creating the session. iOS polls deviceSelector.activeDevice; Android awaits activeDeviceFlow().first { it != null } under withTimeoutOrNull. Automatic (no pin) is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../MetaWearablesDatPlugin.kt | 14 +++++++++++++- .../MetaWearablesDatPlugin.swift | 18 +++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt index e32e4db..bc399e1 100644 --- a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt +++ b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt @@ -45,6 +45,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -1042,7 +1043,8 @@ class MetaWearablesDatPlugin : // rebuild the shared selector and rebind every observer. The // availability watchdog is relaunched by ensureSessionStarted() // below (it re-reads deviceSelector()). - if (deviceId != pinnedDeviceId) { + val pinChanged = deviceId != pinnedDeviceId + if (pinChanged) { pinnedDeviceId = deviceId teardownSession() deviceSelectorRef = makeDeviceSelector() @@ -1050,6 +1052,16 @@ class MetaWearablesDatPlugin : deviceStateStreamHandler?.restartMonitoring(force = true) } + // The just-rebuilt selector resolves its active device + // asynchronously; creating the session before it resolves + // returns noEligibleDevice. When a specific device is pinned, + // wait briefly for it to resolve. + if (pinChanged && deviceId != null) { + withTimeoutOrNull(8_000L) { + deviceSelector().activeDeviceFlow().first { it != null } + } + } + sessionKey = key frameProcessor.configure(fps) diff --git a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift index d93ce33..8ab65ad 100644 --- a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift +++ b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift @@ -50,6 +50,11 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { // the SDK leaves in a non-terminal state forever would otherwise hang the // awaiting Dart future indefinitely. private let deviceSessionStartTimeout: TimeInterval = 20.0 + // After a pin change the shared selector is rebuilt and resolves its active + // device asynchronously; `createSession` against an unresolved selector + // returns `noEligibleDevice`. Bound how long we wait for the pinned device + // to resolve before attempting the session. + private let selectorResolveTimeout: TimeInterval = 8.0 // Timestamp of the last `AutoDeviceSelector` rebuild (see // `rebuildDeviceSelectorIfBlind`). A freshly-rebuilt selector needs a moment // to discover devices; this debounces back-to-back rebuilds so we don't @@ -873,7 +878,8 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { // Apply a pin change (or clear) before creating the session: rebuild the // shared selector and rebind every observer (internal watchdog + the two // event-channel handlers) so none keep watching the old selector. - if requestedDeviceId != pinnedDeviceId { + let pinChanged = requestedDeviceId != pinnedDeviceId + if pinChanged { pinnedDeviceId = requestedDeviceId await teardownDeviceSession() deviceSelector = makeDeviceSelector() @@ -883,6 +889,16 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { deviceStateHandler?.restartMonitoring(force: true) } + // The just-rebuilt selector resolves its active device asynchronously; + // creating the session before it resolves returns `noEligibleDevice`. + // When a specific device is pinned, wait briefly for it to resolve. + if pinChanged, requestedDeviceId != nil { + let deadline = Date().addingTimeInterval(selectorResolveTimeout) + while deviceSelector.activeDevice == nil, Date() < deadline { + try? await Task.sleep(nanoseconds: 150_000_000) + } + } + guard let registry = textureRegistry else { result(FlutterError(code: "TEXTURE_ERROR", message: "Texture registry not available", details: nil)) return From 5b32cdc8af68e0e1e4bf9a93e59e58dc762c231e Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Fri, 19 Jun 2026 18:07:08 +0200 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20polish=200.6.0=20docs=20=E2=80=94?= =?UTF-8?q?=20coherent=20deviceId=20examples,=20Copilot=20getDevices,=20de?= =?UTF-8?q?p=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: streaming snippets pass null (auto) with a pinning comment instead of an undefined `deviceId` variable. - Copilot instructions: add getDevices() / pinning / STREAM_ACTIVE and rename the streaming param deviceUUID -> deviceId (it was behind on both 0.6.0 features). - Bump core + mock dependency hints from ^0.5.x to ^0.6.0 across README, AGENTS, and the agent rules/skills (^0.5.x would exclude the 0.6.0 packages). - Mock-device examples keep `deviceUUID` (the mock's own pair UUID) — passing it now correctly pins the mock. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 4 ++-- README.md | 18 ++++++++++-------- agent/claude/skills/camera-streaming.md | 2 +- agent/claude/skills/mock-device-testing.md | 4 ++-- agent/cursor/rules/dat-mock-device.mdc | 4 ++-- agent/github/copilot-instructions.md | 9 +++++---- 6 files changed, 22 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a2c318b..f7097b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -469,8 +469,8 @@ Develop and test without physical Meta glasses. Mock support lives in the option ```yaml # pubspec.yaml — add only in dev/staging configs dependencies: - flutter_meta_wearables_dat: ^0.5.2 - flutter_meta_wearables_dat_mock_device: ^0.5.2 + flutter_meta_wearables_dat: ^0.6.0 + flutter_meta_wearables_dat_mock_device: ^0.6.0 ``` ```dart diff --git a/README.md b/README.md index 495d221..b70ee70 100644 --- a/README.md +++ b/README.md @@ -268,9 +268,11 @@ The plugin follows Meta's integration lifecycle as documented in the [Meta Weara - Call `MetaWearablesDat.stopStreamSession(deviceId)` to end the session ```dart -// Start streaming — returns a texture ID for zero-copy rendering +// Start streaming — returns a texture ID for zero-copy rendering. +// Pass null to auto-select the active pair, or a WearableDevice.id from +// getDevices() to stream from a specific pair. final textureId = await MetaWearablesDat.startStreamSession( - deviceId, + null, fps: 24, streamQuality: StreamQuality.low, videoCodec: VideoCodec.raw, // or VideoCodec.hvc1 (iOS only, supports background streaming) @@ -314,12 +316,12 @@ for (final d in devices) { // Capture a photo during streaming final photo = await MetaWearablesDat.capturePhoto( - deviceId, + null, format: PhotoCaptureFormat.jpeg, // or PhotoCaptureFormat.heic ); // Stop streaming when done -await MetaWearablesDat.stopStreamSession(deviceId); +await MetaWearablesDat.stopStreamSession(null); ``` Video frames are pushed directly from native (CVPixelBuffer on iOS, SurfaceTexture on Android) to the Flutter engine — no JPEG encoding, no byte copying, no Dart-side decoding. @@ -354,10 +356,10 @@ await MetaWearablesDat.enableBackgroundStreaming( ), ); -final textureId = await MetaWearablesDat.startStreamSession(deviceId); +final textureId = await MetaWearablesDat.startStreamSession(null); // ...later, when you no longer need background execution: -await MetaWearablesDat.stopStreamSession(deviceId); +await MetaWearablesDat.stopStreamSession(null); await MetaWearablesDat.disableBackgroundStreaming(); ``` @@ -454,8 +456,8 @@ Meta gates registration on real glasses, so during development it's often handy ```yaml # pubspec.yaml — add only in dev/staging builds dependencies: - flutter_meta_wearables_dat: ^0.5.2 - flutter_meta_wearables_dat_mock_device: ^0.5.2 + flutter_meta_wearables_dat: ^0.6.0 + flutter_meta_wearables_dat_mock_device: ^0.6.0 ``` ```dart diff --git a/agent/claude/skills/camera-streaming.md b/agent/claude/skills/camera-streaming.md index 1c7036c..00de322 100644 --- a/agent/claude/skills/camera-streaming.md +++ b/agent/claude/skills/camera-streaming.md @@ -132,7 +132,7 @@ The stream switches its underlying subscription automatically when the active de ```dart final photo = await MetaWearablesDat.capturePhoto( - null, // or specific deviceUUID + null, // or a WearableDevice.id from getDevices() to target a specific pair format: PhotoCaptureFormat.jpeg, // or .heic (iOS only) ); // photo.bytes — Uint8List of the image diff --git a/agent/claude/skills/mock-device-testing.md b/agent/claude/skills/mock-device-testing.md index 6af1865..5fd417b 100644 --- a/agent/claude/skills/mock-device-testing.md +++ b/agent/claude/skills/mock-device-testing.md @@ -11,8 +11,8 @@ Since `flutter_meta_wearables_dat` 0.4.0 the mock APIs live in a separate option ```yaml # pubspec.yaml dependencies: - flutter_meta_wearables_dat: ^0.5.2 - flutter_meta_wearables_dat_mock_device: ^0.5.2 + flutter_meta_wearables_dat: ^0.6.0 + flutter_meta_wearables_dat_mock_device: ^0.6.0 ``` ```dart diff --git a/agent/cursor/rules/dat-mock-device.mdc b/agent/cursor/rules/dat-mock-device.mdc index 1d8cff5..d2502dd 100644 --- a/agent/cursor/rules/dat-mock-device.mdc +++ b/agent/cursor/rules/dat-mock-device.mdc @@ -10,8 +10,8 @@ Mock APIs live in the optional add-on `flutter_meta_wearables_dat_mock_device` ( ## Add the dev-only dependency ```yaml dependencies: - flutter_meta_wearables_dat: ^0.5.0 - flutter_meta_wearables_dat_mock_device: ^0.5.0 + flutter_meta_wearables_dat: ^0.6.0 + flutter_meta_wearables_dat_mock_device: ^0.6.0 ``` ```dart diff --git a/agent/github/copilot-instructions.md b/agent/github/copilot-instructions.md index 2a9b442..4734742 100644 --- a/agent/github/copilot-instructions.md +++ b/agent/github/copilot-instructions.md @@ -12,20 +12,21 @@ All methods are static on `MetaWearablesDat`. Single import: `import 'package:fl 2. `startRegistration()` → user confirms in Meta AI → `handleUrl(url)` deep link callback 3. `restartActiveDeviceMonitoring()` — call after registration (critical on Android) 4. `requestCameraPermission()` — Meta AI permission bottom sheet -5. `startStreamSession(deviceUUID, fps:, streamQuality:, videoCodec:)` → returns `textureId` +5. `startStreamSession(deviceId, fps:, streamQuality:, videoCodec:)` → returns `textureId` — pass a `WearableDevice.id` from `getDevices()` to pin a specific pair, or `null` to auto-select 6. Render: `Texture(textureId: textureId)` — zero-copy GPU rendering ### Key methods - `registrationStateStream()` — RegistrationState: unavailable(0), available(1), registering(2), registered(3) - `activeDeviceStream()` — bool, device availability +- `getDevices()` → `List` (id, name, type, linkState, compatibility, supportsDisplay, isActive, isStreamingDevice). Pass an `id` to `startStreamSession` to pin that pair; requesting a *different* device while one is streaming throws `PlatformException` `STREAM_ACTIVE`. Android throws `NOT_INITIALIZED` if called before Bluetooth permission. - `streamSessionStateStream()` — stopping(0), stopped(1), waitingForDevice(2), starting(3), streaming(4), paused(5) - `streamSessionErrorStream()` — StreamSessionError with code/message. Codes: thermalCritical, thermalEmergency, peakPowerShutdown, batteryCritical, hingesClosed, permissionDenied, deviceNotConnected, datAppOnTheGlassesUpdateRequired (recover via `openDATGlassesAppUpdate()`), dwaUnavailable, plus device-session variants (deviceThermalCritical etc.). - `deviceStateStream()` — `Stream` of live `ThermalLevel` (unknown, none, light, moderate, severe, critical, emergency, shutdown). Use to warn the user *before* a thermal error stops the stream. - `openDATGlassesAppUpdate()` — opens the Meta AI app to the DAT-app-update screen. Pair with the `datAppOnTheGlassesUpdateRequired` error code to drive a "tap to update" UI. - `videoStreamSizeStream()` — VideoStreamSize (width/height/aspectRatio) emitted on stream start and resolution changes -- `stopStreamSession(deviceUUID)` — end session -- `capturePhoto(deviceUUID, format:)` — PhotoCaptureFormat.jpeg or .heic +- `stopStreamSession(deviceId)` — end session +- `capturePhoto(deviceId, format:)` — PhotoCaptureFormat.jpeg or .heic - `captureStreamFrame(textureId, width:, height:, format:)` — Dart-side rasterization for ML/OCR. Returns `null` while backgrounded. - `enableBackgroundStreaming(androidNotification: BackgroundNotification(...))` — opt-in; call BEFORE `startStreamSession()` to keep the session alive while backgrounded or screen-locked. iOS + Android. `BackgroundNotification` is required on Android. - `disableBackgroundStreaming()` — tears down the iOS `AVAudioSession` / stops the Android foreground service. Idempotent. @@ -35,7 +36,7 @@ All methods are static on `MetaWearablesDat`. Single import: `import 'package:fl Mock support lives in the optional add-on `flutter_meta_wearables_dat_mock_device` (since 0.4.0). Production builds that omit it skip `MWDATMockDevice` linkage and don't need `NSCameraUsageDescription` / `CAMERA`. -- Add `flutter_meta_wearables_dat_mock_device: ^0.5.2` to dev/staging `pubspec.yaml`. +- Add `flutter_meta_wearables_dat_mock_device: ^0.6.0` to dev/staging `pubspec.yaml`. - Import: `import 'package:flutter_meta_wearables_dat_mock_device/flutter_meta_wearables_dat_mock_device.dart';` - Optional bypass for registration/permission flows: `MetaWearablesDatMockDevice.configure(initiallyRegistered: true, initialPermissionsGranted: true)` - Lifecycle: `MetaWearablesDatMockDevice.pairRayBanMeta()` → UUID, then `.powerOn(uuid)` + `.don(uuid)`, optionally `.setCameraFacing(uuid, CameraFacing.back)`