diff --git a/AGENTS.md b/AGENTS.md index f0afea0..f7097b0 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, }) @@ -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/CHANGELOG.md b/CHANGELOG.md index bfc6719..1bdf6eb 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 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 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..b70ee70 100644 --- a/README.md +++ b/README.md @@ -258,18 +258,21 @@ 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 +// 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( - deviceUUID, + null, fps: 24, streamQuality: StreamQuality.low, videoCodec: VideoCodec.raw, // or VideoCodec.hvc1 (iOS only, supports background streaming) @@ -313,12 +316,12 @@ for (final d in devices) { // Capture a photo during streaming final photo = await MetaWearablesDat.capturePhoto( - deviceUUID, + null, format: PhotoCaptureFormat.jpeg, // or PhotoCaptureFormat.heic ); // Stop streaming when done -await MetaWearablesDat.stopStreamSession(deviceUUID); +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. @@ -353,10 +356,10 @@ await MetaWearablesDat.enableBackgroundStreaming( ), ); -final textureId = await MetaWearablesDat.startStreamSession(deviceUUID); +final textureId = await MetaWearablesDat.startStreamSession(null); // ...later, when you no longer need background execution: -await MetaWearablesDat.stopStreamSession(deviceUUID); +await MetaWearablesDat.stopStreamSession(null); await MetaWearablesDat.disableBackgroundStreaming(); ``` @@ -453,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/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..00de322 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, @@ -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-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-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/cursor/rules/dat-streaming.mdc b/agent/cursor/rules/dat-streaming.mdc index d0161e3..cad12ac 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 (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 - `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/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)` 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..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 @@ -123,9 +124,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` @@ -142,6 +165,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 @@ -163,7 +190,7 @@ class MetaWearablesDatPlugin : ) activeDeviceStreamHandler = ActiveDeviceStreamHandler( - { deviceSelector }, + { deviceSelector() }, { btPermissionsGranted }, { ensureWearablesInitialized() }, ) @@ -220,7 +247,7 @@ class MetaWearablesDatPlugin : ) deviceStateStreamHandler = DeviceStateStreamHandler( - deviceSelectorProvider = { deviceSelector }, + deviceSelectorProvider = { deviceSelector() }, isInitialized = { btPermissionsGranted }, ) deviceStateChannel.setStreamHandler(deviceStateStreamHandler) @@ -636,7 +663,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,36 +960,108 @@ 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 (stream != null) { - val entry = textureEntry - if (entry != null) { - result.success(entry.id()) - } else { - result.error( - "TEXTURE_REGISTRATION_FAILED", - "No texture registered for session $key", - null, - ) + // 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( + "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 { 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()). + val pinChanged = deviceId != pinnedDeviceId + if (pinChanged) { + pinnedDeviceId = deviceId + teardownSession() + deviceSelectorRef = makeDeviceSelector() + activeDeviceStreamHandler?.restartMonitoring(force = true) + 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) @@ -1069,6 +1168,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 } } } @@ -1089,9 +1190,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 @@ -1126,9 +1227,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. @@ -1141,7 +1250,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..57aa7ec 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,55 @@ 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; + + /// 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; + _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 +225,7 @@ class StreamSessionProvider extends ChangeNotifier { @override void dispose() { + mockDeviceProvider.removeListener(_onMockDeviceChanged); _activeDeviceSubscription?.cancel(); _sessionStateSubscription?.cancel(); _sessionErrorSubscription?.cancel(); @@ -300,7 +360,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 +378,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 +421,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/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 new file mode 100644 index 0000000..35437b6 --- /dev/null +++ b/example/test/selection_policy_test.dart @@ -0,0 +1,137 @@ +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(); + }, + ); + + 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); + }); +} 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..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 @@ -8,12 +8,34 @@ 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. 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() -> 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? @@ -28,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 @@ -412,7 +439,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,20 +838,66 @@ 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 streamSession != nil { - if let texId = textureId { - result(texId) - } else { - result(FlutterError(code: "TEXTURE_ERROR", message: "Session exists but no texture registered", details: nil)) - } + // 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: "STREAM_ACTIVE", message: "A stream is already active on another device. Stop it before switching devices.", details: nil)) + } + return + } + // Stale (stopped/stopping) stream — drop the reference and recreate. + await teardownStreamOnly() + } + + // 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. + let pinChanged = requestedDeviceId != pinnedDeviceId + if pinChanged { + pinnedDeviceId = requestedDeviceId + await teardownDeviceSession() + deviceSelector = makeDeviceSelector() + lastSelectorRebuild = Date() + startDeviceAvailabilityMonitoring() + activeDeviceHandler?.restartMonitoring(force: true) + 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)) 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'), + ), + ); + }); +}