diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e59c2..902b4d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.1 +* 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. * 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/lib/main.dart b/example/lib/main.dart index 006708d..eb2d9c4 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,8 +149,10 @@ class _MyAppState extends State with WidgetsBindingObserver { builder: (ctx) => const PairedDevicesSheet(), ); }, - child: const Icon( - Icons.devices, + child: Image.asset( + 'assets/images/cameraAccessIcon.png', + width: 24, + height: 24, color: Colors.white, ), ), diff --git a/example/lib/providers/stream_provider.dart b/example/lib/providers/stream_provider.dart index 57aa7ec..624442f 100644 --- a/example/lib/providers/stream_provider.dart +++ b/example/lib/providers/stream_provider.dart @@ -75,6 +75,12 @@ 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; 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/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 8ab65ad..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 @@ -61,6 +61,32 @@ 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? + // 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 + // 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 +166,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 +190,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 +303,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 +316,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 +468,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 +497,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 +523,93 @@ 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() + // A relaunched subscription hasn't confirmed the current device list yet, + // 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 { + 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 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 { + // 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 + // `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 +1192,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 = didReceiveDeviceListEmission ? knownDeviceIds : Wearables.shared.devices + 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: