| Value | Image size |
|---|---|
SMALL | 480p class |
MEDIUM | 720p class (default) |
LARGE | 1080p class |
FULL | Native sensor, 4032 × 3024 |
FULL has fixed dimensions. The glasses may resolve the other three values slightly differently, so treat them as size classes rather than exact pixel counts.| Value | Trade-off |
|---|---|
LOW | Highest compression, smallest file |
MEDIUM | Balanced (default) |
HIGH | Lowest compression, largest file |
FULL at HIGH produces the largest payload and takes the longest to arrive.| Field | Android | iOS | Notes |
|---|---|---|---|
Image bytes | imageData: ByteArray | imageData: Data | JPEG or HEIC, as produced by the glasses. Read EXIF from these bytes. |
Metadata | metadata: ByteArray? | metadata: Data? | Opaque capture-notification bytes. Not EXIF. Nullable by API contract. |
Timestamp | timestamp: Long | timestamp: Date | Phone-side time when the first transport half arrived, not the moment the shutter fired. Milliseconds on Android. |
Use https://wearables.developer.meta.com/docs/develop/dat/camera-capture/, then use the Wearables MCP endpoint https://mcp.developer.meta.com/wearables to call search_dat_docs for current Device Access Toolkit camera capture guidance. Inspect my camera code first, then add the smallest change that subscribes to the photo, transfer-progress, and error streams before the capability activates, waits for either a started or a terminal stopped outcome under a timeout, and only then calls capturePhoto. Keep one capture in flight at a time, never run Stream and Photo together, recreate the camera on a setup failure instead of reusing the stopped one, treat capture metadata as nullable and read EXIF from the image bytes, and run the relevant local checks.
0.11.0 or newer of the Wearables Device Access Toolkit. Standalone photo capture is not available in earlier public releases.STARTED state.CAMERA device permission, granted by the wearer through the Meta AI app. See Permissions and registration.capturePhoto(), the glasses take the picture, and the image transfers to the phone. Your app observes transfer progress and then receives the completed image.capturePhoto() before the first completes, you cannot tell which result belongs to which call.photo.start(), so the startup sequence differs by platform. Each is covered in its own section below.CAPTURE_FAILURE. A busy camera, an unavailable capture service, a permission denial, and a critical device-health state all arrive as CAPTURE_FAILURE, so BUSY, SERVICE_UNAVAILABLE, PERMISSION_DENIED, and DEVICE_HEALTH_CRITICAL never reach your app. Handle CAPTURE_FAILURE as a general capture failure and do not branch on those four values. iOS reports each of them as a distinct case.deviceDisconnected. The case exists so the enumeration matches Android, but a disconnect during capture surfaces as captureFailure, and only once the 30-second silence timeout expires. Treat a captureFailure that arrives with no transfer progress as a possible disconnect and check the device session state. Android reports DEVICE_DISCONNECTED directly.PhotoError in the API reference for Android and iOS. For recovery guidance, see Troubleshooting and known issues.[libraries]
mwdat-core = { group = "com.meta.wearable", name = "mwdat-core", version.ref = "mwdat" }
mwdat-camera = { group = "com.meta.wearable", name = "mwdat-camera", version.ref = "mwdat" }
mwdat-mockdevice = { group = "com.meta.wearable", name = "mwdat-mockdevice", version.ref = "mwdat" }
dependencies {
implementation(libs.mwdat.core)
implementation(libs.mwdat.camera)
testImplementation(libs.mwdat.mockdevice)
}
checkPermissionStatus() is a suspend function and returns a DatResult, so call it from a coroutine and unwrap it before comparing.private suspend fun isCameraPermissionGranted(): Boolean {
val status = Wearables.checkPermissionStatus(Permission.CAMERA).getOrThrow()
return status == PermissionStatus.Granted
}
Wearables.RequestPermissionContract(). See Permissions and registration.start() on Android. The photo child is created and activated implicitly on first access to camera.photo, which means activation is already under way by the time you can subscribe to anything.photoStream, transferProgressStream, and errors are SharedFlows that do not replay. Subscribe to all three and wait for the subscriptions to be established before doing anything else. Then drive the startup decision from state, which is a StateFlow and therefore cannot miss the outcome.private suspend fun startPhotoCapture(
session: DeviceSession,
scope: CoroutineScope,
): Pair<Camera, Photo>? {
val camera = session.addCamera().getOrThrow()
val photo = camera.photo
val subscribed = CompletableDeferred<Unit>()
val remaining = AtomicInteger(3)
fun markSubscribed() {
if (remaining.decrementAndGet() == 0) subscribed.complete(Unit)
}
scope.launch {
photo.errors
.onSubscription { markSubscribed() }
.collect { error -> showError(error.description) }
}
scope.launch {
photo.transferProgressStream
.onSubscription { markSubscribed() }
.collect { progress -> updateProgress(progress.fraction) }
}
scope.launch {
photo.photoStream
.onSubscription { markSubscribed() }
.collect { data -> saveImage(data.imageData) }
}
subscribed.await()
var sawStarting = false
val outcome = withTimeoutOrNull(PHOTO_STARTUP_TIMEOUT_MS) {
photo.state.first { state ->
when (state) {
PhotoState.STARTING -> { sawStarting = true; false }
PhotoState.STARTED -> true
// A return to STOPPED after STARTING is a setup failure.
PhotoState.STOPPED -> sawStarting
PhotoState.STOPPING -> false
}
}
}
if (outcome != PhotoState.STARTED) {
camera.stop()
return null
}
return camera to photo
}
SETUP_FAILED emitted before your collectors attach is lost, which is why the timeout — not the error stream — is what bounds this wait.photo.capturePhoto(
resolution = PhotoResolution.FULL,
quality = PhotoQuality.HIGH,
)
MEDIUM. Calling capturePhoto in any state other than STARTED reports NOT_READY instead of taking a picture.camera.stop()
stop() cascades to both children and detaches the capability from the session. No separate removal call is needed.MockCameraCaptureKit, reached as glasses.services.cameraCapture.MockCameraCaptureKit is the standalone-photo control surface. It is a different type from MockCameraKit, which controls the video stream. Confusing setCapturedPhoto() here with the stream kit’s image controls is a common source of a mock that never delivers.val fixture = File(context.cacheDir, "capture-fixture.jpg") fixture.writeBytes(testImageBytes) val mockKit = MockDeviceKit.getInstance(context) mockKit.enable() val glasses = mockKit.pairGlasses(GlassesModel.RAYBAN_META).getOrThrow() glasses.services.cameraCapture.setCapturedPhoto(Uri.fromFile(fixture))
simulateCaptureFailure() makes the next capture respond with an error instead of an image; the flag resets after a single failure, which suits a retry test.import MWDATCamera import MWDATCore
Wearables.shared requires an earlier Wearables.configure() call during app startup; see Build an integration for iOS.var status = try await Wearables.shared.checkPermissionStatus(.camera)
if status != .granted {
status = try await Wearables.shared.requestPermission(.camera)
}
guard status == .granted else { return }
photo.start(). Register every listener before calling it: the publishers do not replay, and start() can resolve .started or a setup error before the next statement runs.ListenerTokenBag so they are released together.@MainActor
final class PhotoCaptureController {
private var camera: Camera?
private var photo: Photo?
private let tokenBag = ListenerTokenBag()
private var didCapture = false
func start(on session: DeviceSession) throws {
// `addCamera` returns nil until the session is `.started`.
guard let camera = try session.addCamera() else { return }
let photo = camera.photo
self.camera = camera
self.photo = photo
photo.statePublisher.listen { [weak self] state in
Task { @MainActor in self?.handle(state) }
}.store(in: tokenBag)
photo.photoDataPublisher.listen { [weak self] data in
Task { @MainActor in self?.save(data.imageData) }
}.store(in: tokenBag)
photo.transferProgressPublisher.listen { [weak self] progress in
Task { @MainActor in self?.updateProgress(progress.fraction) }
}.store(in: tokenBag)
photo.errorPublisher.listen { [weak self] error in
Task { @MainActor in self?.show(error) }
}.store(in: tokenBag)
photo.start()
}
private func handle(_ state: PhotoState) {
// `.started` can be published more than once. Capture on the first one only.
guard state == .started, !didCapture else { return }
didCapture = true
photo?.capturePhoto(resolution: .full, quality: .high)
}
}
stop() while the capability is .starting is recorded and honored as soon as startup resolves, rather than dropped.camera.stop()
stop() cascades to both children. iOS exposes no public camera-removal call.MockCameraCaptureKit mirrors the Android surface, taking a URL instead of a Uri.import MWDATMockDevice MockDeviceKit.shared.enable() let mockGlasses = try MockDeviceKit.shared.pairGlasses(model: .rayBanMeta) mockGlasses.services.cameraCapture.setCapturedPhoto(fileURL: fixtureURL) mockGlasses.services.cameraCapture.simulateCaptureFailure()
pairGlasses(model:) throws MockDeviceKitError.notEnabled if you call it before enable().start(): the photo child is created and activated implicitly on first access to camera.photo. iOS requires an explicit photo.start() after you register listeners.camera.stop() cascades to both children and detaches the capability from the session, so no separate removal call is needed. iOS exposes no public camera-removal call.CAPTURE_FAILURE, and iOS reports them distinctly but never emits deviceDisconnected. See Error handling.MockCameraCaptureKit takes a Uri on Android and a URL on iOS.SETUP_FAILED on Android and sessionSetupFailed on iOS.STARTED under a timeout, and treat a return to STOPPED as a startup failure.addCamera() rather than re-reading the old instance.photoStream, transferProgressStream, and errors do not replay, and on Android activation begins the moment you read camera.photo. A capture that completes before your collectors attach is delivered to nobody.start()?start() can resolve before the following line runs.STARTED? A capturePhoto() in any other state reports NOT_READY and takes no picture.STARTED rather than retrying into a paused one.FULL at HIGH is the slowest path. Watch transferProgressStream to confirm bytes are still arriving; 30 seconds of total silence fails the capture.metadata is nullable by API contract on both platforms, and a null value does not mean the capture failed. It carries opaque capture-notification bytes, not EXIF. Read EXIF from imageData instead. Code that parses metadata unconditionally will crash on an otherwise successful capture.