Skip to content

fix(ios): surface devices after camera-permission grant; Android parity#14

Merged
gdelataillade merged 5 commits into
mainfrom
fix/ios-post-permission-device-discovery
Jun 26, 2026
Merged

fix(ios): surface devices after camera-permission grant; Android parity#14
gdelataillade merged 5 commits into
mainfrom
fix/ios-post-permission-device-discovery

Conversation

@gdelataillade

Copy link
Copy Markdown
Collaborator

Problem

Reproduced on iOS (0.6.0, real glasses, 2 paired+worn pairs): after the user accepts the Meta AI camera-access prompt, the SDK reports no eligible devicestartStreamSession fails with noEligibleDevice, activeDeviceStream never emits a non-nil device, and getDevices() returns 0.

Root cause

The plugin only ever observed the AutoDeviceSelector's activeDeviceStream() — it never subscribed to Wearables.shared.devicesStream() / addDevicesListener. On iOS, Wearables.shared.devices is a cold snapshot that stays warm only while something is actively collecting devicesStream(), and glasses surface in that flow only after a camera-permission grant. The selector is created at registrar time (pre-registration) and can stay blind; with nothing watching the device flow, the post-grant emission was observed by nothing, so the selector never resolved and the snapshot read empty. Meta's canonical iOS sample keeps a devicesStream() subscription alive from configure() onward — that was the missing piece.

Android is structurally different: Wearables.devices is a hot StateFlow (confirmed against the decompiled mwdat-core-0.7.0.aar) and DeviceSelectorBase derives its activeDeviceState as a StateFlow from it, so the selector self-resolves and never goes permanently blind. The cold-snapshot bug doesn't exist there — only the post-grant timing needs parity.

Changes

iOS (MetaWearablesDatPlugin.swift)

  • startDeviceListMonitoring() — lifetime devicesStream() subscription started right after configure(); warms a knownDeviceIds cache and deterministically re-warms a blind selector when devices appear.
  • Relaunches that subscription on the restartActiveDeviceMonitoring (disconnect/re-register) recovery path.
  • requestCameraPermission waits (bounded, 8s) for a device to resolve on a grant; returns early on a routine re-check when no device is present.
  • getDevices() reads the warm cache (snapshot fallback only before the first emission).
  • Re-entrancy guard on rebuildDeviceSelectorIfBlind (now has concurrent callers).

Android (MetaWearablesDatPlugin.kt) — parity: requestCameraPermission re-kicks active-device/device-state monitoring and bounded-waits for the selector to resolve. No device-list subscription needed (hot StateFlow).

DartrequestCameraPermission documents the bounded post-grant wait.

Bumps to 0.6.1 + CHANGELOG.

Verification

  • flutter build ios --no-codesign → ✅ built
  • :flutter_meta_wearables_dat:compileDebugKotlin → ✅ success
  • dart analyze → no issues
  • Adversarial multi-dimension review (Swift compile / iOS concurrency / Android parity / end-to-end efficacy / contract): 3 low-severity findings, all addressed.

Not yet validated

On-device check with 2 paired+worn pairs (accept prompt → activeDevice non-nil in seconds → startStreamSession(null) succeeds → getDevices() returns 2) — needs a maintainer with hardware.

🤖 Generated with Claude Code

gdelataillade and others added 2 commits June 22, 2026 10:33
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@gdelataillade

Copy link
Copy Markdown
Collaborator Author

Good catch on the P1 — fixed in 2509544.

knownDeviceIds.isEmpty couldn't tell "not emitted yet" from "confirmed empty," so the already-granted fast path could return before the first devicesStream() emission warmed the selector. Added a didReceiveDeviceListEmission flag (set on each emission, reset when the subscription is relaunched) and gated the early return on it: a pre-first-emission empty cache now falls through to the bounded wait instead of bailing.

The fresh-grant path is unaffected (it never early-returns), and Android needs no equivalent change — it reads Wearables.devices.value, a hot StateFlow that always carries a current confirmed value, so it has no not-emitted-yet state.

Verified flutter build ios --no-codesign still builds clean.

@gdelataillade
gdelataillade merged commit 00b7c82 into main Jun 26, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant