Capture high-quality photos

Updated: Sep 28, 2026

Overview

The Camera capability exposes two child features:
  • Stream for real-time video frames, including in-stream photo capture.
  • Photo for standalone, high-quality still capture at up to the native sensor resolution.
This page covers Photo. Use it when you want a full-quality still image rather than a frame lifted out of a video stream.
Beta: Standalone photo capture is available to every developer, but apps that use it cannot be published yet. Build and test against it; do not ship a production app that depends on it.

Resolution

ValueImage size
SMALL
480p class
MEDIUM
720p class (default)
LARGE
1080p class
FULL
Native sensor, 4032 × 3024
Only the 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.

Quality

ValueTrade-off
LOW
Highest compression, smallest file
MEDIUM
Balanced (default)
HIGH
Lowest compression, largest file
Higher resolution and higher quality both increase on-device processing time and transfer time. FULL at HIGH produces the largest payload and takes the longest to arrive.

Completed captures

FieldAndroidiOSNotes
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 this page with AI

Copy this prompt into your AI coding tool to add or debug standalone photo capture:

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.

Prerequisites

  • Version 0.11.0 or newer of the Wearables Device Access Toolkit. Standalone photo capture is not available in earlier public releases.
  • A working integration for Android or iOS.
  • A device session in the STARTED state.
  • The CAMERA device permission, granted by the wearer through the Meta AI app. See Permissions and registration.

Capability lifecycle

Stream and Photo compete for the camera hardware and cannot capture at the same time. Stopping one before starting the other is your app’s responsibility; the toolkit does not arbitrate between them.
A capture request is asynchronous. You call capturePhoto(), the glasses take the picture, and the image transfers to the phone. Your app observes transfer progress and then receives the completed image.
Keep at most one capture in flight. Completion and progress events carry no request identifier, and the photo stream is not a per-request response channel. On iOS, photos the wearer takes with the shutter button on the glasses arrive on the same publisher as the ones your app requests. If you issue a second capturePhoto() before the first completes, you cannot tell which result belongs to which call.
Android activates the photo child implicitly and iOS requires an explicit photo.start(), so the startup sequence differs by platform. Each is covered in its own section below.

Error handling

In the current release, the error you receive can be less specific than the platform enumerations suggest. Both gaps are platform-specific.
Android reports device-side failures as 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.
iOS does not emit 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.
For the enumerated values on each platform, see PhotoError in the API reference for Android and iOS. For recovery guidance, see Troubleshooting and known issues.

Android

Step 1: Add the dependencies

Add the camera artifact alongside the core artifact in your version catalog, using the same version reference as the rest of the SDK.
[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)
}

Step 2: Check the camera permission

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
}
When the permission is not granted, launch the grant flow with Wearables.RequestPermissionContract(). See Permissions and registration.

Step 3: Add a camera, subscribe, and wait for startup

There is no 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.
That ordering matters because 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
}
A SETUP_FAILED emitted before your collectors attach is lost, which is why the timeout — not the error stream — is what bounds this wait.

Step 4: Capture

photo.capturePhoto(
    resolution = PhotoResolution.FULL,
    quality = PhotoQuality.HIGH,
)
Both parameters default to MEDIUM. Calling capturePhoto in any state other than STARTED reports NOT_READY instead of taking a picture.

Step 5: Stop

camera.stop()
stop() cascades to both children and detaches the capability from the session. No separate removal call is needed.

Test without hardware

Drive capture from Mock Device Kit through 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.
Point it at a real file. A fabricated URI string cannot be opened by the mock.
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))
Without a configured image, the mock device reports that no capture is available. 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.
Mock Device Kit exercises delivery and generic failure handling. It does not model Stream and Photo hardware contention, and its transfer progress is not representative of a real device. Validate both on hardware.

iOS

Step 1: Import the modules

import MWDATCamera
import MWDATCore

Step 2: Check the camera permission

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 }

Step 3: Register listeners, then start

Unlike Android, iOS requires an explicit 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.
Retain each listener token for as long as you want its callback to fire. Store them in a 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)
    }
}
Calling stop() while the capability is .starting is recorded and honored as soon as startup resolves, rather than dropped.

Step 4: Stop

camera.stop()
stop() cascades to both children. iOS exposes no public camera-removal call.

Test without hardware

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().
The same limits apply as on Android: delivery and generic failure handling are exercised, but Stream and Photo contention and realistic transfer progress are not.

Platform differences

Standalone photo capture behaves the same on both platforms except in these places:
  • Startup. Android has no 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.
  • Teardown. On Android, 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.
  • Wearer-initiated photos. On iOS, photos the wearer takes with the shutter button on the glasses arrive on the same publisher as the ones your app requests. Serialize your own captures so you can still tell the results apart.
  • Error detail. Android collapses four device-side failures into CAPTURE_FAILURE, and iOS reports them distinctly but never emits deviceDisconnected. See Error handling.
  • Mock control surface.MockCameraCaptureKit takes a Uri on Android and a URL on iOS.

Troubleshooting and known issues

Capture reliability

Standalone photo capture is not yet as reliable as the video stream. Treat a failed capture as an expected outcome and build a retry path rather than assuming success.
Startup is the most common failure point. Sessions intermittently fail to start the photo capability, and the following attempt then succeeds. This surfaces as SETUP_FAILED on Android and sessionSetupFailed on iOS.
Handle it with a bounded retry:
  1. Wait for the photo child to reach STARTED under a timeout, and treat a return to STOPPED as a startup failure.
  2. On failure, stop the camera, add a new one, and retry once. A stopped camera is invalidated, so the retry must go through a fresh addCamera() rather than re-reading the old instance.
  3. Surface an error to the wearer only after the retry also fails.

A capture never arrives

  • Turn the glasses off and back on, then try again. Restarting the glasses clears a stuck capture service on the device and resolves many cases on its own. Do this before you start debugging your app.
  • Did you subscribe before the capability activated?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.
  • Did you register iOS listeners before start()?start() can resolve before the following line runs.
  • Was the state STARTED? A capturePhoto() in any other state reports NOT_READY and takes no picture.
  • Is the stream running? Stream and Photo compete for the camera hardware. Stop the stream first.
  • Is more than one capture in flight? Results carry no request identifier. Serialize your captures.
  • Did the session pause? A paused device session suspends transports. Wait for the session to return to STARTED rather than retrying into a paused one.
  • Is the payload very large?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 null on a successful 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.
See also Known issues for issues tracked across the toolkit.

Next steps