Skip to main content
New Participant
October 1, 2025
Question

[Quest 3] Accessing Camera via Android Camera2 API - Process Succeeds but Image is Black

Hello everyone,

I'm developing a native Android application (integrated with Unreal Engine) for the Meta Quest 3. My goal is to programmatically capture a still image from the passthrough camera using the standard Android Camera2 API.

First, I want to emphasize that the standard, system-level Passthrough feature works perfectly in the headset. To be clear, I already have Passthrough enabled and working correctly inside my Unreal app. I can see the live camera feed, which confirms the basic functionality is there.

My issue is specifically with capturing a still image programmatically. While my code executes without errors and saves a file, the resulting image is always completely black.

I'm aware that this was a known limitation on older OS versions. However, I was under the impression this was resolved starting with software v67, based on the official documentation here:
https://developers.meta.com/horizon/documentation/native/android/pca-native-documentation

Given that my headset is on a much newer version, I'm struggling to understand what I'm still missing.

Here's a summary of my setup and what I've confirmed:

Android Manifest & Permissions:
MyAndroidManifest.xmlis configured according to the documentation and includes all necessary features and permissions. I can confirm through logs that the user grants the runtime permissions (CAMERA and HEADSET_CAMERA) successfully.

Here are the relevant entries from my manifest:

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="horizonos.permission.HEADSET_CAMERA"/>
<uses-feature android:name="android.hardware.camera2.any" android:required="true"/>
<uses-feature android:name="com.oculus.feature.PASSTHROUGH" android:required="true"/>

Camera Discovery:
I am using the standardCamera2API to find the correct passthrough camera device. This part of the code works perfectly—I successfully identify the camera ID by checking CameraCharacteristics for the Meta-specific metadata key (com.meta.extra_metadata.camera_source).
The Problem: The Black Image
My code successfully opens the camera, creates a capture session, captures an image, and saves a JPEG file. The entire process completes without any exceptions, and my native function receives a "success" code.

However, the saved JPEG file is always completely black.
My system version is: v79.1032 so as my per understanding of
https://developers.meta.com/horizon/documentation/native/android/pca-native-documentation
It should work
My Java class which is called to save the image, hello method is invoked to capture the image:

package com.jaszczurcompany.camerahelper;
import android.Manifest;
import android.app.Activity;
import android.content.ContentValues;
import android.content.pm.PackageManager;
import android.graphics.ImageFormat;
import android.graphics.SurfaceTexture;
import android.hardware.camera2.*;
import android.hardware.camera2.params.StreamConfigurationMap;
import android.media.Image;
import android.media.ImageReader;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.HandlerThread;
import android.provider.MediaStore;
import android.util.Log;
import android.util.Size;
import android.view.Surface;
import androidx.annotation.NonNull;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class CameraHelper {
    private static final String TAG = "FooTag"; // Zmieniono TAG, aby pasował do Twoich logów

    public static final int SUCCESS = 0;
    // ... (reszta kodów bez zmian)
    public static final int ERROR_NO_CAMERA_FEATURE = -2;
    public static final int ERROR_MISSING_PERMISSIONS = -3;
    public static final int ERROR_CAMERA_MANAGER_UNAVAILABLE = -4;
    public static final int ERROR_NO_PASSTHROUGH_CAMERA_FOUND = -5;
    public static final int ERROR_CAMERA_ACCESS_DENIED = -6;
    public static final int ERROR_CAMERA_SESSION_FAILED = -7;
    public static final int ERROR_CAPTURE_FAILED = -8;
    public static final int ERROR_IMAGE_SAVE_FAILED = -9;
    public static final int ERROR_TIMEOUT = -10;
    public static final int ERROR_INTERRUPTED = -11;
    public static final int ERROR_UNSUPPORTED_CONFIGURATION = -12;

    private static final String META_PASSTHROUGH_CAMERA_KEY_NAME = "com.meta.extra_metadata.camera_source";
    private static final byte META_PASSTHROUGH_CAMERA_VALUE = 1;

    private final Activity activity;
    private CameraManager cameraManager;
    private CameraDevice cameraDevice;
    private CameraCaptureSession captureSession;
    private ImageReader imageReader;
    private Handler backgroundHandler;
    private HandlerThread backgroundThread;
    private SurfaceTexture dummyPreviewTexture;
    private Surface dummyPreviewSurface;

    private final CountDownLatch operationCompleteLatch = new CountDownLatch(1);
    private final AtomicInteger resultCode = new AtomicInteger(SUCCESS);
    private final Semaphore cameraOpenCloseLock = new Semaphore(1);

    public CameraHelper(@NonNull Activity activity) {
        this.activity = activity;
    }

    public int hello() {
        Log.d(TAG, "Starting hello() sequence using Camera2 API (Two-Session approach).");

        // ... (wstępne sprawdzenia są takie same)
        if (!activity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) return ERROR_NO_CAMERA_FEATURE;
        cameraManager = (CameraManager) activity.getSystemService(Activity.CAMERA_SERVICE);
        if (cameraManager == null) return ERROR_CAMERA_MANAGER_UNAVAILABLE;
        if (!checkPermissions()) return ERROR_MISSING_PERMISSIONS;


        try {
            startBackgroundThread();
            if (!cameraOpenCloseLock.tryAcquire(5, TimeUnit.SECONDS)) return ERROR_TIMEOUT;

            String cameraId = findPassthroughCameraId(cameraManager);
            if (cameraId == null) {
                setResult(ERROR_NO_PASSTHROUGH_CAMERA_FOUND);
            } else {
                openCamera(cameraId);
            }

            if (!operationCompleteLatch.await(15, TimeUnit.SECONDS)) setResult(ERROR_TIMEOUT);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            setResult(ERROR_INTERRUPTED);
        } catch (CameraAccessException e) {
            setResult(ERROR_CAMERA_ACCESS_DENIED);
        } finally {
            cleanup();
        }
        Log.i(TAG, "hello() returned " + resultCode.get());
        return resultCode.get();
    }
    
    // Zmieniono nazwę, aby nie mylić z prośbą o uprawnienia
    private boolean checkPermissions() {
        String[] requiredPermissions = {Manifest.permission.CAMERA, "horizonos.permission.HEADSET_CAMERA"};
        return Arrays.stream(requiredPermissions)
                     .allMatch(p -> ContextCompat.checkSelfPermission(activity, p) == PackageManager.PERMISSION_GRANTED);
    }

    private String findPassthroughCameraId(CameraManager manager) throws CameraAccessException {
        // ... (bez zmian)
        final CameraCharacteristics.Key<Byte> metaCameraSourceKey = new CameraCharacteristics.Key<>(META_PASSTHROUGH_CAMERA_KEY_NAME, Byte.class);
        for (String cameraId : manager.getCameraIdList()) {
            CameraCharacteristics characteristics = manager.getCameraCharacteristics(cameraId);
            Byte cameraSourceValue = characteristics.get(metaCameraSourceKey);
            if (cameraSourceValue != null && cameraSourceValue == META_PASSTHROUGH_CAMERA_VALUE) return cameraId;
        }
        return null;
    }

    private void openCamera(String cameraId) throws CameraAccessException {
        // ... (konfiguracja imageReader i dummyPreviewSurface bez zmian)
        CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId);
        StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
        if (map == null) { setResult(ERROR_UNSUPPORTED_CONFIGURATION); return; }
        Size largestSize = Arrays.stream(map.getOutputSizes(ImageFormat.JPEG)).max(Comparator.comparing(s -> (long) s.getWidth() * s.getHeight())).orElse(new Size(1280, 720));
        Log.d(TAG, "Selected JPEG size: " + largestSize);
        imageReader = ImageReader.newInstance(largestSize.getWidth(), largestSize.getHeight(), ImageFormat.JPEG, 1);
        imageReader.setOnImageAvailableListener(this::onImageAvailable, backgroundHandler);
        dummyPreviewTexture = new SurfaceTexture(10);
        dummyPreviewTexture.setDefaultBufferSize(640, 480);
        dummyPreviewSurface = new Surface(dummyPreviewTexture);

        if (ActivityCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) return;
        cameraManager.openCamera(cameraId, cameraStateCallback, backgroundHandler);
    }

    private final CameraDevice.StateCallback cameraStateCallback = new CameraDevice.StateCallback() {
        @Override public void onOpened(@NonNull CameraDevice camera) {
            cameraOpenCloseLock.release();
            cameraDevice = camera;
            createPreviewSession(); // ZACZNIJ OD SESJI PODGLĄDU
        }
        @Override public void onDisconnected(@NonNull CameraDevice camera) { /* ... bez zmian ... */ cameraOpenCloseLock.release(); setResult(ERROR_CAMERA_ACCESS_DENIED); camera.close(); }
        @Override public void onError(@NonNull CameraDevice camera, int error) { /* ... bez zmian ... */ cameraOpenCloseLock.release(); setResult(ERROR_CAMERA_ACCESS_DENIED); camera.close(); }
    };

    /** KROK 1: Utwórz i uruchom sesję TYLKO dla podglądu, aby rozgrzać sensor. */
    private void createPreviewSession() {
        try {
            CaptureRequest.Builder previewRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
            previewRequestBuilder.addTarget(dummyPreviewSurface);

            cameraDevice.createCaptureSession(Collections.singletonList(dummyPreviewSurface), new CameraCaptureSession.StateCallback() {
                @Override public void onConfigured(@NonNull CameraCaptureSession session) {
                    captureSession = session;
                    try {
                        session.setRepeatingRequest(previewRequestBuilder.build(), null, backgroundHandler);
                        Log.d(TAG, "Preview started for warm-up. Waiting 1s...");
                        backgroundHandler.postDelayed(() -> {
                            // Po opóźnieniu, zatrzymaj podgląd i zacznij przechwytywanie
                            stopPreviewAndStartCapture();
                        }, 10000);
                    } catch (CameraAccessException e) {
                        setResult(ERROR_CAPTURE_FAILED);
                    }
                }
                @Override public void onConfigureFailed(@NonNull CameraCaptureSession session) {
                    setResult(ERROR_CAMERA_SESSION_FAILED);
                }
            }, backgroundHandler);
        } catch (CameraAccessException e) {
            setResult(ERROR_CAMERA_SESSION_FAILED);
        }
    }

    /** KROK 2: Zatrzymaj sesję podglądu i utwórz nową sesję do zrobienia zdjęcia. */
    private void stopPreviewAndStartCapture() {
        try {
            captureSession.stopRepeating();
            captureSession.close(); // Zamknij starą sesję
            Log.d(TAG, "Preview stopped. Creating capture session...");

            // Utwórz nową sesję TYLKO dla ImageReader
            cameraDevice.createCaptureSession(Collections.singletonList(imageReader.getSurface()), new CameraCaptureSession.StateCallback() {
                @Override public void onConfigured(@NonNull CameraCaptureSession session) {
                    captureSession = session;
                    captureImage(); // Zrób zdjęcie używając nowej sesji
                }
                @Override public void onConfigureFailed(@NonNull CameraCaptureSession session) {
                    setResult(ERROR_CAMERA_SESSION_FAILED);
                }
            }, backgroundHandler);
        } catch (CameraAccessException e) {
            setResult(ERROR_CAPTURE_FAILED);
        }
    }

    /** KROK 3: Zrób pojedyncze zdjęcie. */
    private void captureImage() {
        try {
            Log.d(TAG, "Capturing image with dedicated session...");
            CaptureRequest.Builder captureBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE);
            captureBuilder.addTarget(imageReader.getSurface());
            captureSession.capture(captureBuilder.build(), null, backgroundHandler);
        } catch (CameraAccessException e) {
            setResult(ERROR_CAPTURE_FAILED);
        }
    }

    private void onImageAvailable(ImageReader reader) {
        // ... (bez zmian)
        try (Image image = reader.acquireLatestImage()) {
            if (image == null) return;
            ByteBuffer buffer = image.getPlanes()[0].getBuffer();
            byte[] bytes = new byte[buffer.remaining()];
            buffer.get(bytes);
            saveImage(bytes);
        } catch (Exception e) {
            setResult(ERROR_IMAGE_SAVE_FAILED);
        }
    }

    private void saveImage(byte[] bytes) {
        // ... (bez zmian)
        String fileName = "IMG_" + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()) + ".jpg";
        ContentValues values = new ContentValues();
        values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
        values.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg");
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
        }
        android.net.Uri uri = activity.getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
        if (uri == null) { setResult(ERROR_IMAGE_SAVE_FAILED); return; }
        try (OutputStream os = activity.getContentResolver().openOutputStream(uri)) {
            if (os == null) throw new java.io.IOException("Output stream is null.");
            os.write(bytes);
            setResult(SUCCESS);
        } catch (java.io.IOException e) {
            setResult(ERROR_IMAGE_SAVE_FAILED);
        }
    }

    private void setResult(int code) {
        // ... (bez zmian)
        if (resultCode.compareAndSet(SUCCESS, code)) {
            operationCompleteLatch.countDown();
        }
    }
    
    // ... (metody start/stop background thread i cleanup są takie same, bez większych zmian)
    private void startBackgroundThread() { /* ... bez zmian ... */ backgroundThread = new HandlerThread("CameraBackground"); backgroundThread.start(); backgroundHandler = new Handler(backgroundThread.getLooper()); }
    private void stopBackgroundThread() { /* ... bez zmian ... */ if (backgroundThread != null) { backgroundThread.quitSafely(); try { backgroundThread.join(1000); } catch (InterruptedException e) { /* ignore */ } backgroundThread = null; backgroundHandler = null; } }
    private void cleanup() {
        try { if (!cameraOpenCloseLock.tryAcquire(2500, TimeUnit.MILLISECONDS)) Log.e(TAG, "Cleanup timeout."); }
        catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        try {
            if (captureSession != null) { captureSession.close(); captureSession = null; }
            if (cameraDevice != null) { cameraDevice.close(); cameraDevice = null; }
            if (imageReader != null) { imageReader.close(); imageReader = null; }
            if (dummyPreviewSurface != null) { dummyPreviewSurface.release(); dummyPreviewSurface = null; }
            if (dummyPreviewTexture != null) { dummyPreviewTexture.release(); dummyPreviewTexture = null; }
        } finally {
            cameraOpenCloseLock.release();
            stopBackgroundThread();
        }
    }
}

Earlier, when I created a similar thread [Quest 3] Accessing Camera via Android Camera2, I was advised to increase the onConfigured callback, but this happens here:

                        session.setRepeatingRequest(previewRequestBuilder.build(), null, backgroundHandler);
                        Log.d(TAG, "Preview started for warm-up. Waiting 1s...");
                        backgroundHandler.postDelayed(() -> {
                            // Po opóźnieniu, zatrzymaj podgląd i zacznij przechwytywanie
                            stopPreviewAndStartCapture();
                        }, 10000);

Additionally, in the previous thread there was a suggestion to retry fetching the image, but every subsequent one is still black.

From logcat I see that it succeeded but the output image is still black.
Many thanks to VirtuallyARealDinosaur​  for the previous replies.

3 replies

VirtuallyARealDinosaur
Community Manager
October 2, 2025

Hey SpacePogona​ ! I am happy to see you've reposted. I'm going to update the devs who were advising me on your issue.

Thanks :)

New Participant
February 7, 2026

Hi,

 

Thanks for posting this. I am also facing a similar issue. Trying to save the image frame as JPG and everything completes without any error however, the image doe not open in file explorer application installed on my Quest 3 Device.

I am using Meta Spatial SDK and coding in Kotlin.

New Participant
February 7, 2026

Right after posting the reply I figured out the answer. The image object that we receive has an image format of 35 which doesn't seem to be JPEG so writing a single plane won't work. My image has 3 planes so I used a function like following to save the image and it works:

 

fun saveYuvImageToFile(image: Image) {
val name = "FBT_${System.currentTimeMillis()}.jpg"
val folder = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"${fbTicket.Name}"
)

if (!folder.exists()) {
folder.mkdirs(); // This creates the directory if it's missing
}

val file = File(folder.absolutePath, name)

val planes = image.getPlanes()
val yBuffer = planes[0]!!.getBuffer()
val uBuffer = planes[1]!!.getBuffer()
val vBuffer = planes[2]!!.getBuffer()

val ySize = yBuffer.remaining()
val uSize = uBuffer.remaining()
val vSize = vBuffer.remaining()

val nv21Data = ByteArray(ySize + uSize + vSize)


yBuffer.get(nv21Data, 0, ySize)
vBuffer.get(nv21Data, ySize, vSize)
uBuffer.get(nv21Data, ySize + vSize, uSize)

val yuvImage =
YuvImage(nv21Data, ImageFormat.NV21, image.getWidth(), image.getHeight(), null)

try {
FileOutputStream(file).use { out ->
// Compress the YuvImage to JPEG format and save to the FileOutputStream
yuvImage.compressToJpeg(
Rect(0, 0, image.getWidth(), image.getHeight()),
100,
out
) // 80 is the quality
out.close()
}
Log.e(TAG, "image saved")
} catch (e: IOException) {
e.printStackTrace()
Log.e(TAG, "saving image ${e}")
} finally {
image.close() // Important to close the image
}
}

 

Hope this helps someone.

 

Have a great day ahead !!!!