From 8d9a72bf491c57eac7ee80e00072edff3efe95cf Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Mon, 22 Jun 2026 10:33:12 +0200 Subject: [PATCH 1/5] fix(ios): surface devices after camera-permission grant; Android parity The plugin only ever observed the device selector's active-device stream, never the SDK device list itself. On iOS, Wearables.shared.devices is a cold snapshot that stays warm only while something subscribes to devicesStream(), and glasses surface in that flow only *after* a camera-permission grant. With no subscriber, the pre-registration AutoDeviceSelector stayed permanently blind and the snapshot read empty, so startStreamSession failed with noEligibleDevice and getDevices() returned 0 even with paired, worn glasses. iOS: - Keep a lifetime devicesStream() subscription (Meta's canonical pattern, started right after configure()) that warms a device-list cache and deterministically re-warms a blind selector when devices appear. - Relaunch that subscription on the disconnect/re-register recovery path. - requestCameraPermission now waits (bounded) for a device to resolve on a grant, so an immediately-following startStreamSession / getDevices doesn't race discovery; returns early on a routine re-check with no device present. - getDevices() reads the warm cache (snapshot fallback only pre-first-emit). - Add a re-entrancy guard to rebuildDeviceSelectorIfBlind (now has concurrent callers). Android (parity): requestCameraPermission re-kicks monitoring and bounded- waits for the selector to resolve. Android's device list is a hot StateFlow that stays warm and the selector tracks it, so no device-list subscription is needed there. Docs: requestCameraPermission documents the bounded post-grant wait. Bumps to 0.6.1. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 + .../MetaWearablesDatPlugin.kt | 45 ++++++ example/pubspec.lock | 2 +- .../MetaWearablesDatPlugin.swift | 138 +++++++++++++++++- lib/flutter_meta_wearables_dat.dart | 10 +- pubspec.yaml | 2 +- 6 files changed, 198 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e59c2..8641229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.6.1 +* **iOS: Fix `noEligibleDevice` and empty `getDevices()` right after granting the camera-access prompt.** The plugin only ever observed the device selector's active-device stream and never the SDK device list itself; glasses surface in the SDK device flow *after* a permission grant, so with nothing watching that flow the selector stayed blind and `Wearables.shared.devices` read empty — `startStreamSession()` failed with `noEligibleDevice` and `getDevices()` returned 0 even with paired, worn glasses. The plugin now keeps a lifetime `devicesStream()` subscription (Meta's canonical pattern, started right after `configure()`) that warms the device list and deterministically re-warms a blind selector when devices appear. `getDevices()` reads the warm list. +* `requestCameraPermission()` now waits briefly (bounded) on a grant for the glasses to resolve before returning, so an immediately-following `startStreamSession()` / `getDevices()` doesn't race device discovery. Returns as soon as a device resolves, and returns immediately on a routine re-check when no device is present. A `false` return still means the user denied the request. +* Android: parity — `requestCameraPermission()` re-kicks active-device monitoring and waits (bounded) for the selector to resolve on a grant. Android's device list is already a hot stream that stays warm, so it needed no equivalent of the iOS device-list subscription. + ## 0.6.0 * Add `getDevices()` and wearable device types for listing paired glasses, connection state, compatibility, and the active/streaming pair. * Add device pinning via `startStreamSession(deviceId)` (`null` keeps automatic selection), with `STREAM_ACTIVE` protection and a paired-device picker in the example app. 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 bc399e1..039a4a8 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 @@ -62,6 +62,11 @@ class MetaWearablesDatPlugin : private const val PERMISSION_REQUEST_CODE = 48291 private const val BT_PERMISSION_REQUEST_CODE = 48292 private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 48294 + // Upper bound on how long we wait, after a camera-permission grant, for + // the shared selector to resolve a device before returning to Dart. + // Granting permission implies the glasses are connected, so a device + // should appear well inside this bound. + private const val PERMISSION_DEVICE_RESOLVE_TIMEOUT_MS = 8_000L private val REQUIRED_PERMISSIONS: Array = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { arrayOf( @@ -541,6 +546,7 @@ class MetaWearablesDatPlugin : val currentStatus = checkCameraPermissionStatus(result) ?: return@withLock if (currentStatus == PermissionStatus.Granted) { + awaitDeviceAfterPermissionGrant(returnEarlyIfNoDevices = true) result.success(true) return@withLock } @@ -582,6 +588,13 @@ class MetaWearablesDatPlugin : } val permissionStatus = parseResult.getOrNull() ?: PermissionStatus.Denied + if (permissionStatus == PermissionStatus.Granted) { + // The grant brings the glasses (back) into the SDK device + // flow. Re-kick monitoring and wait (bounded) for the + // selector to resolve a device so the app's next + // startStreamSession / getDevices doesn't race the SDK. + awaitDeviceAfterPermissionGrant() + } result.success(permissionStatus == PermissionStatus.Granted) } catch (e: Exception) { result.error( @@ -594,6 +607,38 @@ class MetaWearablesDatPlugin : } } + /** + * After a camera-permission grant the glasses (re)surface in the SDK device + * flow. Re-kick active-device / device-state monitoring and give the shared + * selector a bounded window to resolve a device, so the app's next + * startStreamSession / getDevices doesn't race the SDK and hit + * noEligibleDevice / an empty list. + * + * Unlike iOS, Android's `Wearables.devices` is a hot `StateFlow` that stays + * warm post-init and the `AutoDeviceSelector` tracks it, so there is no + * cold-snapshot device list or permanently-blind selector to rebuild here — + * the selector resolves on its own once a device is present. We only need to + * (re)kick the observers and wait. + */ + private suspend fun awaitDeviceAfterPermissionGrant( + returnEarlyIfNoDevices: Boolean = false, + ) { + activeDeviceStreamHandler?.restartMonitoring() + deviceStateStreamHandler?.restartMonitoring() + // Already resolved — nothing to wait for. + if (deviceSelector().activeDevice() != null) return + // On the already-granted fast path, if the SDK currently lists no + // devices there is nothing to resolve — return rather than holding the + // permission mutex for the full timeout on a routine re-check. + // `Wearables.devices` is a hot StateFlow, so its value reflects current + // reality; on a *fresh* grant we instead keep waiting (default false), + // since the just-granted device is expected to appear momentarily. + if (returnEarlyIfNoDevices && Wearables.devices.value.isEmpty()) return + withTimeoutOrNull(PERMISSION_DEVICE_RESOLVE_TIMEOUT_MS) { + deviceSelector().activeDeviceFlow().first { it != null } + } + } + // endregion // region Registration diff --git a/example/pubspec.lock b/example/pubspec.lock index f5a52a8..7268c66 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -204,7 +204,7 @@ packages: path: ".." relative: true source: path - version: "0.6.0" + version: "0.6.1" flutter_meta_wearables_dat_mock_device: dependency: "direct main" description: 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 8ab65ad..a0ed46e 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 @@ -61,6 +61,26 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { // discard a still-discovering selector and prolong the no-device window. private var lastSelectorRebuild: Date? private let selectorRebuildCooldown: TimeInterval = 3.0 + // Serializes `rebuildDeviceSelectorIfBlind` across its now-multiple concurrent + // callers — see the guard there for why the cooldown alone isn't enough. + private var isRebuildingSelector = false + // Most recent device-identifier list observed on + // `Wearables.shared.devicesStream()`. The SDK surfaces devices reactively and + // only *after* a camera-permission grant; Meta's CameraAccess sample keeps a + // `devicesStream()` subscription alive from `configure()` onward so the + // discovery pipeline stays warm. The plugin used to observe only the + // selector's `activeDeviceStream()` — never the raw device list — so a device + // that surfaced post-grant was caught by nothing, leaving `getDevices()` empty + // and the selector blind. `startDeviceListMonitoring()` fixes that: it feeds + // this cache and deterministically re-warms the selector when devices appear. + private var knownDeviceIds: [DeviceIdentifier] = [] + private var devicesStreamTask: Task? + // Upper bound on how long `awaitDeviceAfterPermissionGrant` waits for a device + // to resolve after a camera-permission grant. Comfortably exceeds + // `selectorRebuildCooldown` so a blind selector can be rebuilt and resolve + // within the window. Granting permission implies the glasses are connected, + // so a device should appear well inside this bound. + private let permissionDeviceResolveTimeout: TimeInterval = 8.0 // Stream session state (single session at a time) private var streamSession: MWDATCamera.Stream? @@ -140,6 +160,10 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { Task { @MainActor in try? Wearables.configure() + // Keep a lifetime subscription on the SDK device list (Meta's canonical + // pattern, started right after `configure()`) so discovery stays warm and + // devices that surface after a permission grant are actually observed. + instance.startDeviceListMonitoring() instance.startDeviceAvailabilityMonitoring() } } @@ -160,6 +184,13 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { // registration — see `rebuildDeviceSelectorIfBlind`), it is recreated // and the loops are force-restarted onto the new instance. Task { @MainActor in + // The SDK terminates its device streams on unregistration — including + // `devicesStream()`, which feeds `knownDeviceIds` and drives the + // selector re-warm. Relaunch that lifetime subscription here (it's + // idempotent: `startDeviceListMonitoring` cancels any live task first) + // so a disconnect/re-register cycle doesn't leave the device-list + // cache permanently stale — mirroring the sibling availability loop. + self.startDeviceListMonitoring() let rebuilt = await self.rebuildDeviceSelectorIfBlind() self.activeDeviceHandler?.restartMonitoring(force: rebuilt) self.deviceStateHandler?.restartMonitoring(force: rebuilt) @@ -266,6 +297,12 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { // through to it instead of surfacing DEVICE_DISCONNECTED. if let currentStatus = try? await Wearables.shared.checkPermissionStatus(.camera), currentStatus == .granted { + // Already granted, but the selector may still be blind (e.g. the app + // launched already-registered and nothing has resolved a device yet). + // Re-warm and wait briefly so a subsequent stream start succeeds — + // but skip the wait when no device is known (nothing to resolve), so + // a routine re-check doesn't block for the full timeout. + await awaitDeviceAfterPermissionGrant(returnEarlyIfNoDevices: true) result(true) return } @@ -273,6 +310,14 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { let status = try await Wearables.shared.requestPermission(.camera) // Convert PermissionStatus enum to Bool for Flutter let granted = status == .granted + if granted { + // The grant is exactly what brings the glasses (back) into + // `devicesStream()`. Re-warm the selector and wait (bounded) for a + // device to resolve so the app's next `startStreamSession` / + // `getDevices` doesn't race the SDK and hit `noEligibleDevice` / an + // empty list — the failure this whole change set targets. + await awaitDeviceAfterPermissionGrant() + } result(granted) } catch let e as MWDATCore.PermissionError { let code = Self.mapPermissionError(e) @@ -417,6 +462,16 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { /// force-restart loops still attached to the old instance. @MainActor private func rebuildDeviceSelectorIfBlind() async -> Bool { + // Re-entrancy guard. This method now has two concurrent callers (the + // devices-stream loop and the post-permission wait) on top of the existing + // `restartActiveDeviceMonitoring` path. There is an `await` between the + // cooldown check and the `lastSelectorRebuild` write below, so without this + // flag two entrants could both pass the guards while one is suspended in + // `teardownDeviceSession()` and double-rebuild. The check-and-set is atomic + // on the main actor (no `await` between them). + if isRebuildingSelector { + return false + } if let session = deviceSession, session.state == .started { return false } @@ -436,6 +491,8 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { Date().timeIntervalSince(last) < selectorRebuildCooldown { return false } + isRebuildingSelector = true + defer { isRebuildingSelector = false } // Any idle/stopped leftover session is bound to the old selector — drop // it so the next startStreamSession creates against the fresh one. await teardownDeviceSession() @@ -460,6 +517,79 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { } } + /// Keeps a lifetime subscription on `Wearables.shared.devicesStream()` so the + /// SDK's device discovery stays warm and the plugin always holds an + /// up-to-date device list. This mirrors Meta's CameraAccess sample, which + /// subscribes immediately after `configure()`. It is the missing observer + /// that left `getDevices()` empty and the selector blind after a camera + /// permission grant: glasses only surface on `devicesStream()` *after* a + /// grant, and nothing in the plugin used to watch that stream. When devices + /// appear while the shared selector is still blind, re-warm it so the active + /// device resolves deterministically rather than via a racy retry. + @MainActor + private func startDeviceListMonitoring() { + devicesStreamTask?.cancel() + devicesStreamTask = Task { [weak self] in + guard let self else { return } + for await ids in Wearables.shared.devicesStream() { + self.knownDeviceIds = ids.filter { !$0.isEmpty } + if !self.knownDeviceIds.isEmpty { + await self.rewarmSelectorForNewlyAppearedDevices() + } + } + } + } + + /// Rebuilds the shared selector (and rebinds its observers) when devices have + /// appeared but the selector is still blind. Reuses `rebuildDeviceSelectorIfBlind` + /// — which no-ops when a session is started, the selector already has an + /// active device, or a rebuild happened within the cooldown — so concurrent + /// callers (the devices-stream loop and `awaitDeviceAfterPermissionGrant`) + /// can't thrash the selector. + @MainActor + private func rewarmSelectorForNewlyAppearedDevices() async { + let rebuilt = await rebuildDeviceSelectorIfBlind() + if rebuilt { + activeDeviceHandler?.restartMonitoring(force: true) + deviceStateHandler?.restartMonitoring(force: true) + } + } + + /// After a camera-permission grant the glasses (re)surface on + /// `devicesStream()`. Give the SDK a bounded window to discover a device and + /// the shared selector to resolve one — re-warming the selector if it's blind + /// — so the app's next `startStreamSession` / `getDevices` doesn't race the + /// SDK. Returns as soon as a device resolves; otherwise after the timeout. + /// + /// Pass `returnEarlyIfNoDevices: true` on the already-granted fast path: a + /// grant that already happened would have surfaced the glasses on + /// `devicesStream()`, so an empty `knownDeviceIds` there means there is + /// genuinely no device to wait for (glasses off / out of range) — returning + /// immediately avoids burning the full timeout on a routine status re-check. + /// On a *fresh* grant we keep waiting: the device is expected imminently and + /// the cache may simply not have emitted yet. + @MainActor + private func awaitDeviceAfterPermissionGrant(returnEarlyIfNoDevices: Bool = false) async { + let deadline = Date().addingTimeInterval(permissionDeviceResolveTimeout) + while Date() < deadline { + if deviceSelector.activeDevice != nil { return } + if knownDeviceIds.isEmpty { + if returnEarlyIfNoDevices { return } + } else { + // Devices are known but the (pre-registration) selector hasn't resolved + // one — rebuild it deterministically. The cooldown inside + // `rebuildDeviceSelectorIfBlind` is shorter than this timeout, so even + // if a rebuild was just debounced it will go through on a later pass. + await rewarmSelectorForNewlyAppearedDevices() + if deviceSelector.activeDevice != nil { return } + } + // The rewarm above can run a teardown + selector rebuild; re-check the + // deadline before sleeping so a late iteration doesn't overshoot it. + if Date() >= deadline { return } + try? await Task.sleep(nanoseconds: 200_000_000) + } + } + /// Returns a DeviceSession in `.started` state, creating one if needed. /// Mirrors the pattern in Meta's CameraAccess sample (DeviceSessionManager). @MainActor @@ -1042,7 +1172,13 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { let sessionId = deviceSession?.deviceId let streaming = streamSession?.state == .streaming var devices: [[String: Any]] = [] - for id in Wearables.shared.devices { + // Prefer the warm cache fed by `startDeviceListMonitoring()`: the raw + // `Wearables.shared.devices` snapshot can read empty when nothing is + // actively observing the device stream (the original bug), whereas the + // cache reflects the most recent `devicesStream()` emission. Fall back to + // the snapshot only before the stream has emitted its first value. + let ids = knownDeviceIds.isEmpty ? Wearables.shared.devices : knownDeviceIds + for id in ids { guard !id.isEmpty else { continue } let isActive = (id == active) let isStreamingDevice = streaming && (id == sessionId) diff --git a/lib/flutter_meta_wearables_dat.dart b/lib/flutter_meta_wearables_dat.dart index 1618553..852af92 100644 --- a/lib/flutter_meta_wearables_dat.dart +++ b/lib/flutter_meta_wearables_dat.dart @@ -657,7 +657,15 @@ class MetaWearablesDat { return MetaWearablesDatPlatform.instance.requestAndroidPermissions(); } - /// Requests camera permission. + /// Requests camera permission from the connected glasses (shows the Meta AI + /// camera-access prompt when not already granted). + /// + /// On a grant, the call waits briefly (bounded) for the glasses to surface in + /// the SDK device flow before returning, so an immediately-following + /// [startStreamSession] or [getDevices] does not race device discovery and + /// hit `noEligibleDevice` / an empty list. Returns as soon as a device + /// resolves; otherwise after a short timeout. A `false` return means the user + /// denied the request. static Future requestCameraPermission() { return MetaWearablesDatPlatform.instance.requestCameraPermission(); } diff --git a/pubspec.yaml b/pubspec.yaml index 3b08a57..0c6ae4a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_meta_wearables_dat description: "Flutter bridge to Meta's Wearables DAT for iOS and Android." -version: 0.6.0 +version: 0.6.1 repository: https://github.com/rodcone/flutter_meta_wearables_dat environment: From 25095445bbccfde50a0e5621b2953d9febd93de2 Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Mon, 22 Jun 2026 10:43:03 +0200 Subject: [PATCH 2/5] fix(ios): gate fast-path early-return on first devicesStream emission Addresses PR review (P1): on the already-granted path, awaitDeviceAfterPermissionGrant(returnEarlyIfNoDevices: true) used knownDeviceIds.isEmpty as "no device", but an empty cache before the devicesStream() subscription delivers its first value means "unknown", not "none". That let the fast path return before the device list arrived and the selector warmed, racing the first emission. Track didReceiveDeviceListEmission (set on each emission, reset when the subscription is relaunched) and gate the early return on it. A pre-first- emission empty cache now falls through to the bounded wait instead of returning. Fresh-grant path is unaffected (it never early-returns). Android needs no change: it reads Wearables.devices.value, a hot StateFlow that always carries a current confirmed value. Co-Authored-By: Claude Opus 4.8 --- .../MetaWearablesDatPlugin.swift | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) 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 a0ed46e..832be23 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 @@ -75,6 +75,12 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { // this cache and deterministically re-warms the selector when devices appear. private var knownDeviceIds: [DeviceIdentifier] = [] private var devicesStreamTask: Task? + // True once the live `devicesStream()` subscription has delivered at least + // one value. Distinguishes "`knownDeviceIds` is empty because nothing has + // emitted yet" (cold start) from "the SDK confirmed there are no devices" — + // only the latter justifies the already-granted fast path returning without + // waiting. Reset whenever the subscription is (re)launched. + private var didReceiveDeviceListEmission = false // Upper bound on how long `awaitDeviceAfterPermissionGrant` waits for a device // to resolve after a camera-permission grant. Comfortably exceeds // `selectorRebuildCooldown` so a blind selector can be rebuilt and resolve @@ -529,9 +535,13 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { @MainActor private func startDeviceListMonitoring() { devicesStreamTask?.cancel() + // A relaunched subscription hasn't confirmed the current device list yet, + // so an empty cache is once again "unknown", not "no devices". + didReceiveDeviceListEmission = false devicesStreamTask = Task { [weak self] in guard let self else { return } for await ids in Wearables.shared.devicesStream() { + self.didReceiveDeviceListEmission = true self.knownDeviceIds = ids.filter { !$0.isEmpty } if !self.knownDeviceIds.isEmpty { await self.rewarmSelectorForNewlyAppearedDevices() @@ -563,18 +573,24 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { /// /// Pass `returnEarlyIfNoDevices: true` on the already-granted fast path: a /// grant that already happened would have surfaced the glasses on - /// `devicesStream()`, so an empty `knownDeviceIds` there means there is - /// genuinely no device to wait for (glasses off / out of range) — returning - /// immediately avoids burning the full timeout on a routine status re-check. - /// On a *fresh* grant we keep waiting: the device is expected imminently and - /// the cache may simply not have emitted yet. + /// `devicesStream()`, so a *confirmed* empty device list there means there is + /// genuinely nothing to wait for (glasses off / out of range) and we return + /// immediately rather than burning the full timeout on a routine re-check. + /// "Confirmed" is load-bearing: the early return is gated on + /// `didReceiveDeviceListEmission`, so a cache that is empty only because the + /// subscription hasn't delivered its first value yet still waits — otherwise + /// the fast path could return before the list arrives and the selector warms. + /// On a *fresh* grant we always keep waiting: the device is expected + /// imminently. @MainActor private func awaitDeviceAfterPermissionGrant(returnEarlyIfNoDevices: Bool = false) async { let deadline = Date().addingTimeInterval(permissionDeviceResolveTimeout) while Date() < deadline { if deviceSelector.activeDevice != nil { return } if knownDeviceIds.isEmpty { - if returnEarlyIfNoDevices { return } + // Only bail when an emission has actually confirmed the empty list; a + // pre-first-emission empty cache is "unknown", so fall through and wait. + if returnEarlyIfNoDevices, didReceiveDeviceListEmission { return } } else { // Devices are known but the (pre-registration) selector hasn't resolved // one — rebuild it deterministically. The cooldown inside From d628754f0c568ab52b9578d7c53f47203eb0be44 Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Fri, 26 Jun 2026 12:27:30 +0200 Subject: [PATCH 3/5] Add minor improvements to example app --- example/lib/main.dart | 21 +++++++++++++-------- example/lib/providers/stream_provider.dart | 7 +++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 006708d..f324868 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -14,7 +14,13 @@ import 'package:flutter_meta_wearables_dat_example/screens/stream/paired_devices import 'package:flutter_meta_wearables_dat_example/screens/stream/stream_screen.dart'; import 'package:provider/provider.dart'; -void main() { +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + runApp(const MyApp()); } @@ -124,9 +130,11 @@ class _MyAppState extends State with WidgetsBindingObserver { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, children: [ - // Paired devices — visible whenever registered, including - // while streaming, so the "Streaming" badge stays reachable. - if (deviceProvider.isRegistered) + // Paired devices — only worth surfacing when more than one + // device is connected, since the sheet exists to switch + // between active pairs. Stays reachable while streaming. + if (deviceProvider.isRegistered && + streamProvider.connectedDeviceCount > 1) Padding( padding: const EdgeInsets.only(right: 5), child: FloatingActionButton.small( @@ -141,10 +149,7 @@ class _MyAppState extends State with WidgetsBindingObserver { builder: (ctx) => const PairedDevicesSheet(), ); }, - child: const Icon( - Icons.devices, - color: Colors.white, - ), + child: Image.asset('assets/images/cameraAccessIcon.png', width: 24, height: 24, color: Colors.white), ), ), // Settings — hidden while streaming to keep the video clear. diff --git a/example/lib/providers/stream_provider.dart b/example/lib/providers/stream_provider.dart index 57aa7ec..d6e8dd1 100644 --- a/example/lib/providers/stream_provider.dart +++ b/example/lib/providers/stream_provider.dart @@ -75,6 +75,13 @@ class StreamSessionProvider extends ChangeNotifier { /// Snapshot of paired devices from the last [refreshDevices] call. List get devices => _devices; + /// Number of connected (active) paired devices from the last + /// [refreshDevices] snapshot. Used to decide whether the paired-devices + /// picker is worth surfacing — switching only makes sense with 2+ connected. + int get connectedDeviceCount => _devices + .where((d) => d.linkState == WearableLinkState.connected) + .length; + /// True while a [refreshDevices] call is in flight. bool get devicesLoading => _devicesLoading; From 8263aec769c138cc17caa2e4629f8b46c5482c28 Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Fri, 26 Jun 2026 12:29:14 +0200 Subject: [PATCH 4/5] Update CHANGELOG --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8641229..902b4d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,6 @@ ## 0.6.1 -* **iOS: Fix `noEligibleDevice` and empty `getDevices()` right after granting the camera-access prompt.** The plugin only ever observed the device selector's active-device stream and never the SDK device list itself; glasses surface in the SDK device flow *after* a permission grant, so with nothing watching that flow the selector stayed blind and `Wearables.shared.devices` read empty — `startStreamSession()` failed with `noEligibleDevice` and `getDevices()` returned 0 even with paired, worn glasses. The plugin now keeps a lifetime `devicesStream()` subscription (Meta's canonical pattern, started right after `configure()`) that warms the device list and deterministically re-warms a blind selector when devices appear. `getDevices()` reads the warm list. -* `requestCameraPermission()` now waits briefly (bounded) on a grant for the glasses to resolve before returning, so an immediately-following `startStreamSession()` / `getDevices()` doesn't race device discovery. Returns as soon as a device resolves, and returns immediately on a routine re-check when no device is present. A `false` return still means the user denied the request. -* Android: parity — `requestCameraPermission()` re-kicks active-device monitoring and waits (bounded) for the selector to resolve on a grant. Android's device list is already a hot stream that stays warm, so it needed no equivalent of the iOS device-list subscription. +* iOS: Fix `noEligibleDevice` and empty `getDevices()` right after camera permission grant by keeping the SDK device list warm. +* `requestCameraPermission()` waits briefly for device discovery before returning (iOS and Android). ## 0.6.0 * Add `getDevices()` and wearable device types for listing paired glasses, connection state, compatibility, and the active/streaming pair. From ccad24e4a7cc8c39477836d1ad9bc96a066ec60a Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Fri, 26 Jun 2026 12:56:24 +0200 Subject: [PATCH 5/5] Address PR review findings --- example/lib/main.dart | 9 +++++++-- example/lib/providers/stream_provider.dart | 5 ++--- flutter_meta_wearables_dat_mock_device/CHANGELOG.md | 4 ++++ flutter_meta_wearables_dat_mock_device/pubspec.yaml | 2 +- .../MetaWearablesDatPlugin.swift | 8 ++++++-- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index f324868..eb2d9c4 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -20,7 +20,7 @@ Future main() async { DeviceOrientation.portraitUp, DeviceOrientation.portraitDown, ]); - + runApp(const MyApp()); } @@ -149,7 +149,12 @@ class _MyAppState extends State with WidgetsBindingObserver { builder: (ctx) => const PairedDevicesSheet(), ); }, - child: Image.asset('assets/images/cameraAccessIcon.png', width: 24, height: 24, color: Colors.white), + child: Image.asset( + 'assets/images/cameraAccessIcon.png', + width: 24, + height: 24, + color: Colors.white, + ), ), ), // Settings — hidden while streaming to keep the video clear. diff --git a/example/lib/providers/stream_provider.dart b/example/lib/providers/stream_provider.dart index d6e8dd1..624442f 100644 --- a/example/lib/providers/stream_provider.dart +++ b/example/lib/providers/stream_provider.dart @@ -78,9 +78,8 @@ class StreamSessionProvider extends ChangeNotifier { /// Number of connected (active) paired devices from the last /// [refreshDevices] snapshot. Used to decide whether the paired-devices /// picker is worth surfacing — switching only makes sense with 2+ connected. - int get connectedDeviceCount => _devices - .where((d) => d.linkState == WearableLinkState.connected) - .length; + int get connectedDeviceCount => + _devices.where((d) => d.linkState == WearableLinkState.connected).length; /// True while a [refreshDevices] call is in flight. bool get devicesLoading => _devicesLoading; diff --git a/flutter_meta_wearables_dat_mock_device/CHANGELOG.md b/flutter_meta_wearables_dat_mock_device/CHANGELOG.md index c5806b1..fff367b 100644 --- a/flutter_meta_wearables_dat_mock_device/CHANGELOG.md +++ b/flutter_meta_wearables_dat_mock_device/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.1 + +* Align version with core package's 0.6.1 release. No API changes. + ## 0.6.0 * Version aligned with the core package's 0.6.0 release (read-only `getDevices()`). No mock-add-on API changes. diff --git a/flutter_meta_wearables_dat_mock_device/pubspec.yaml b/flutter_meta_wearables_dat_mock_device/pubspec.yaml index 6938336..df852e2 100644 --- a/flutter_meta_wearables_dat_mock_device/pubspec.yaml +++ b/flutter_meta_wearables_dat_mock_device/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_meta_wearables_dat_mock_device description: "Optional MockDeviceKit add-on for flutter_meta_wearables_dat — simulates a Ray-Ban Meta device using the phone's camera. Pull this in only for development and testing; production apps should omit it to avoid linking AVFoundation/Camera symbols." -version: 0.6.0 +version: 0.6.1 repository: https://github.com/rodcone/flutter_meta_wearables_dat environment: 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 832be23..906295c 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 @@ -536,11 +536,15 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { private func startDeviceListMonitoring() { devicesStreamTask?.cancel() // A relaunched subscription hasn't confirmed the current device list yet, - // so an empty cache is once again "unknown", not "no devices". + // so an empty cache is once again "unknown", not "no devices". Drop the + // previous emission too so callers can't observe stale IDs while the new + // stream is still waiting for its first value. + knownDeviceIds = [] didReceiveDeviceListEmission = false devicesStreamTask = Task { [weak self] in guard let self else { return } for await ids in Wearables.shared.devicesStream() { + if Task.isCancelled { return } self.didReceiveDeviceListEmission = true self.knownDeviceIds = ids.filter { !$0.isEmpty } if !self.knownDeviceIds.isEmpty { @@ -1193,7 +1197,7 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { // actively observing the device stream (the original bug), whereas the // cache reflects the most recent `devicesStream()` emission. Fall back to // the snapshot only before the stream has emitted its first value. - let ids = knownDeviceIds.isEmpty ? Wearables.shared.devices : knownDeviceIds + let ids = didReceiveDeviceListEmission ? knownDeviceIds : Wearables.shared.devices for id in ids { guard !id.isEmpty else { continue } let isActive = (id == active)