Common Camera Integration Bugs and How to Catch Them

Common Camera Integration Bugs and How to Catch Them

May 25, 2026 · 15 min read · Common Issues

Common Camera Integration Bugs and How to Catch Them

Camera integration is one of the most fragile parts of mobile apps. A single missed permission, a mismatched surface lifecycle, or an unhandled orientation change can turn a promising feature into a source of crashes, poor reviews, and security concerns. This guide walks through the most common camera‑integration bugs, explains why they happen, shows what they look like to users, and gives concrete steps to reproduce, detect, fix, and prevent each issue. Real‑world code snippets, a test matrix, and a bug/symptom/fix table are included so you can turn this knowledge into immediate action.

Common Camera Integration Bugs and How to Catch Them: Permission and Manifest Issues

Missing CAMERA permission

The most frequent cause of a silent failure is forgetting to declare in AndroidManifest.xml. When the permission is absent, Camera.open() (legacy) or CameraManager.openCamera() (Camera2) returns SecurityException, but many developers catch the exception generically and display a vague “camera not available” toast, leaving users confused.

How it looks: The preview surface stays black, the shutter button does nothing, and logcat shows java.lang.SecurityException: Need CAMERA permission.

Reproduce: Install the app on a device with API 23+ where runtime permissions are enforced, then launch the camera flow without granting the permission via Settings → Apps → [YourApp] → Permissions.

Detect: Add a unit test that asserts the manifest contains the permission using the Android Gradle plugin’s manifestPlaceholders or a custom lint rule. In CI, run ./gradlew processDebugManifest and grep the merged manifest for the permission string.

Fix:


<uses-permission android:name="android.permission.CAMERA"/>

For Android 6.0+, request the permission at runtime:


if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
        != PackageManager.PERMISSION_GRANTED) {
    ActivityCompat.requestPermissions(this,
            new String[]{Manifest.permission.CAMERA},
            REQUEST_CAMERA);
}

Handle the callback in onRequestPermissionsResult and only start the camera after PERMISSION_GRANTED.

Prevent: Enforce a manifest lint check that fails the build if the permission is missing. Use a tool like androidx.test.core.app.ApplicationProvider in a Robolectric test to verify that PackageManager.hasPermission(Manifest.permission.CAMERA) returns true after the app’s onCreate.

Incorrect uses-permission-sdk-23

Some projects try to gate the permission behind to avoid prompting on pre‑Marshmallow devices. If the attribute is placed incorrectly (e.g., inside instead of the manifest root), the system ignores it, and the permission is never granted on API 23+.

How it looks: Same as missing permission, but the manifest appears correct at a glance.

Reproduce: Build the app with minSdkVersion 21 and targetSdkVersion 33. Insert the permission inside and run on a Pixel 6 (API 33).

Detect: Run the Android Lint check MissingPermission with the -x flag to report manifest placement errors.

Fix: Move the line to the manifest root:


<manifest ...>
    <uses-permission android:name="android.permission.CAMERA"/>
    <application ...> ... </application>
</manifest>

Prevent: Add a custom Gradle task that validates the manifest structure using XmlPullParser and fails if any tag is not a direct child of .

Runtime permission handling

Even with the manifest correct, mishandling the result of requestPermissions leads to bugs. Common mistakes include starting the camera in onCreate before the user responds, or checking ContextCompat.checkSelfPermission only once and assuming it stays granted.

How it looks: The app crashes with a SecurityException the first time the camera is opened after a denial, or the preview freezes because the camera was opened with a null CameraDevice.

Reproduce: Deny the permission when prompted, then navigate back to the camera screen and try to take a picture.

Detect: Write an Espresso test that simulates the permission dialog using UiObject2 (or the newer GrantPermissionRule) and asserts that the preview surface remains hidden until permission is granted.

Fix:


@Override
public void onRequestPermissionsResult(int requestCode,
        @NonNull String[] permissions, @NonNull int[] grantResults) {
    if (requestCode == REQUEST_CAMERA) {
        if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            startCameraPreview();
        } else {
            showPermissionRationale();
        }
    }
}

Only call startCameraPreview() after a successful grant.

Prevent: Encapsulate permission logic in a reusable PermissionHelper class that returns a LiveData indicating granted status. Observe this LiveData in the UI layer and start the camera only when the value flips to true.

Common Camera Integration Bugs and How to Catch Them: Preview and Surface Issues

SurfaceView lifecycle mismatches

A SurfaceView provides the surface for camera preview, but its lifecycle (surfaceCreated, surfaceChanged, surfaceDestroyed) does not always align with the Activity/Fragment lifecycle. Starting the camera before surfaceCreated or failing to stop it in surfaceDestroyed leads to leaked camera resources and black previews on return.

How it looks: The preview appears correctly the first time, but after navigating away and back the surface stays black, and logcat shows Attempt to call getParameters on a null Camera.

Reproduce: Open the camera, press Home, return to the app via recent‑apps, then try to capture.

Detect: Add log statements in each surface callback and verify that surfaceCreated is called before startPreview() and surfaceDestroyed after stopPreview(). Use the Android Studio Profiler to watch for open camera handles (adb shell dumpsys media.camera).

Fix:


@Override
public void surfaceCreated(SurfaceHolder holder) {
    mHolder = holder;
    if (hasPermission()) {
        openCamera();
    }
}

@Override
public void surfaceDestroyed(SurfaceHolder holder) {
    releaseCamera();
    mHolder = null;
}

Ensure openCamera() checks both permission and holder non‑null, and releaseCamera() calls camera.stopPreview() and camera.release().

Prevent: Create a base CameraSurfaceView class that enforces the correct ordering and throws an IllegalStateException if openCamera() is called before surfaceCreated. Use this base class in all camera screens.

TextureView vs SurfaceView

On API 14+, TextureView can be used to embed the camera preview inside a view hierarchy that supports animations and scrolling. However, TextureView does not guarantee a surface until its isAvailable() callback fires, and it consumes more GPU memory. Misusing it leads to jittery preview or black frames when the view is scrolled out of view.

How it looks: The preview lags behind UI scroll, showing a stale frame, or disappears entirely when placed inside a RecyclerView.

Reproduce: Place a TextureView inside a vertically scrolling LinearLayout, scroll quickly, and observe the preview.

Detect: Espresso test that scrolls the container and asserts that the preview bitmap (captured via TextureView.getBitmap()) updates within 16 ms of a frame change.

Fix: Listen to TextureView.SurfaceTextureListener and start/stop the camera in onSurfaceTextureAvailable and onSurfaceTextureDestroyed. Additionally, pause the preview when the view’s visibility changes to GONE or INVISIBLE by overriding onVisibilityChanged.

Prevent: Wrap the TextureView in a custom CameraPreviewView that centralizes lifecycle callbacks and exposes a simple start()/stop() API. Unit‑test the wrapper with Robolectric to ensure camera calls follow view state.

Preview stretching/distortion

When the preview surface dimensions do not match the camera sensor’s aspect ratio, the image appears stretched or squeezed. This often happens when developers hardcode preview size to match_parent without considering device rotation or screen aspect ratio.

How it looks: Faces appear elongated, barcode scanning fails because the decoded matrix is warped, and users report a “funhouse mirror” effect.

Reproduce: Launch the camera on a tablet in landscape, then rotate to portrait while the preview is active.

Detect: Calculate the expected aspect ratio from CameraCharacteristics.get(SensorInfo.SENSOR_SIZE) and compare it to the actual surface size in onSurfaceChanged. Log a warning if the ratio differs by more than 5 %.

Fix: Choose a preview size from the list returned by CameraCharacteristics.getScaledStreamConfigurationMap().getOutputSizes(SurfaceTexture.class) that best matches the surface’s aspect ratio while staying within the surface bounds. Then set the surface’s setFixedSize(width, height) or adjust the view’s layout parameters accordingly.

Prevent: Create a utility method getOptimalPreviewSize(width, height, characteristics) that encapsulates the selection logic. Use it in every camera initializer and add a unit test that feeds various screen sizes and asserts the returned size respects the aspect ratio tolerance.

Common Camera Integration Bugs and How to Catch Them: Focus and Metering Problems

Auto-focus hunting

Legacy Camera.autoFocus() and Camera2’s AF_MODE_CONTINUOUS_PICTURE can cause the lens to constantly adjust when the scene contains repetitive patterns or low contrast, leading to a distracting “hunting” effect and missed focus locks.

How it looks: The preview continuously blurs and sharpens, and the capture callback returns images with low sharpness scores.

Reproduce: Point the camera at a plain white wall or a high‑frequency grating pattern and watch the focus motor.

Detect: Use the camera’s focus state callback (Camera.AutoFocusCallback or CaptureResult.get(CaptureResult.CONTROL_AF_STATE)) and measure the time between state changes. If the state oscillates between FOCUSED_LOCKED and SCANNING more than three times per second, flag it as hunting.

Fix: Switch to AF_MODE_AUTO for single‑shot focus when the user taps to focus, or use AF_MODE_CONTINUOUS_VIDEO for video capture where slight hunting is tolerable. For picture capture, trigger a focus lock (triggerAfLock) after a short settle period (e.g., 300 ms) and capture only when the state is FOCUSED_LOCKED.

Prevent: Define a focus strategy enum (FIXED, AUTO_SHOT, CONTINUOUS_PICTURE, CONTINUOUS_VIDEO) and expose it via a configuration object. Write a test that injects a mock CameraDevice and verifies the correct AF mode is set based on the strategy.

Tap-to-focus not working

Many apps implement tap-to-focus by converting the touch coordinates to a metering rectangle and calling setAutoFocusMeteringRectangle. Mistakes include using screen pixels instead of sensor‑relative coordinates, forgetting to convert from display orientation to sensor orientation, or providing rectangles outside the valid range (−1000 to 1000).

How it looks: Tapping the screen has no effect on focus, or the focus jumps to an unrelated part of the scene.

Reproduce: Open the preview, tap on a nearby object, and observe that the focus remains on a distant background.

Detect: Espresso test that performs a tap at (x, y) and then reads the resulting metering rectangle via a test‑only hook that exposes the last setAutoFocusMeteringRectangle call. Assert that the rectangle’s center matches the tapped point within a tolerance of 20 px.

Fix:


private void setTapFocus(float x, float y) {
    Rect focusRect = calculateFocusRect(x, y, previewWidth, previewHeight);
    MeteringRectangle metering = new MeteringRectangle(focusRect, MeteringRectangle.METERING_WEIGHT_MAX);
    List<MeteringRectangle> meteringArray = Collections.singletonList(metering);
    try {
        mCaptureRequestBuilder.set(CaptureRequest.CONTROL_AF_REGIONS, meteringArray);
        mCaptureSession.setRepeatingRequest(mCaptureRequestBuilder.build(), null, mHandler);
    } catch (CameraAccessException e) {
        Log.e(TAG, "Unable to set focus region", e);
    }
}

private Rect calculateFocusRect(float screenX, float screenY,
        int previewWidth, int previewHeight) {
    // Convert from view coordinates to sensor coordinates (-1000..1000)
    float normalizedX = (screenX / previewWidth) * 2000 - 1000;
    float normalizedY = (screenY / previewHeight) * 2000 - 1000;
    int halfWidth = 100; // 10% of total range
    int left = Math.round(normalizedX) - halfWidth;
    int top = Math.round(normalizedY) - halfWidth;
    return new Rect(
            clamp(left, -1000, 900),
            clamp(top, -1000, 900),
            clamp(left + 2 * halfWidth, -1000, 1000),
            clamp(top + 2 * halfWidth, -1000, 1000));
}

private int clamp(int val, int min, int max) {
    return Math.max(min, Math.min(max, val));
}

Prevent: Encapsulate the conversion logic in a FocusHelper class with unit tests that feed known screen points and assert the resulting MeteringRectangle. Use the helper in all UI layers.

Exposure lock issues

Locking exposure (AE_MODE_ON_LOCK) after a tap can cause over‑ or under‑exposed images if the lock is applied before the scene stabilizes, or if the lock is never released, leaving subsequent frames stuck at the same exposure.

How it looks: The first picture after locking looks correct, but subsequent pictures are too dark or bright, especially when lighting changes (e.g., moving from indoors to outdoors).

Reproduce: Tap to lock exposure on a bright scene, then quickly point the camera at a dim scene and capture.

Detect: Use the CaptureResult.get(CaptureResult.CONTROL_AE_STATE) to verify that the AE state transitions to CONVERGED before locking, and that after unlocking it returns to SEEKING.

Fix:


private void lockExposure() {
    try {
        mCaptureRequestBuilder.set(CaptureRequest.CONTROL_AE_LOCK, true);
        mCaptureSession.setRepeatingRequest(mCaptureRequestBuilder.build(), null, mHandler);
    } catch (CameraAccessException e) {
        Log.e(TAG, "AE lock failed", e);
    }
}

private void unlockExposure() {
    try {
        mCaptureRequestBuilder.set(CaptureRequest.CONTROL_AE_LOCK, false);
        mCaptureSession.setRepeatingRequest(mCaptureRequestBuilder.build(), null, mHandler);
    } catch (CameraAccessException e) {
        Log.e(TAG, "AE unlock failed", e);
    }
}

Call lockExposure() only after receiving AE_STATE_CONVERGED for two consecutive frames, and always pair it with an unlockExposure() after the capture or after a timeout (e.g., 4 s).

Prevent: Create an ExposureController that exposes lock(), unlock(), and isLocked() and internally tracks AE state via a CameraCaptureSession.CaptureCallback. Unit‑test the controller with a mock session that feeds predefined AE states.

Common Camera Integration Bugs and How to Catch Them: Camera2 API Specific Bugs

CaptureSession configuration failures

Creating a CameraCaptureSession can fail with CameraAccessException if the configured output surfaces are incompatible (e.g., mixing a SurfaceTexture with an ImageReader that has an unsupported format). The failure is often swallowed, leaving the preview never started.

How it looks: The preview surface stays black, and logcat shows Failed to create capture session: Configuration fails.

Reproduce: Attempt to create a session with a SurfaceTexture preview and an ImageReader set to ImageFormat.YUV_420_888 while also requesting a JPEG output without having added a proper OutputConfiguration.

Detect: Wrap session creation in a try/catch and log the exception. In tests, use a fake CameraDevice that throws CameraAccessException when createCaptureSession is called with an invalid set of surfaces, and assert that your error‑handling path displays a user‑friendly message.

Fix: Validate the output configurations before calling createCaptureSession. Use CameraCharacteristics.getScaledStreamConfigurationMap().getOutputSizes(ImageFormat.JPEG) to ensure the requested size is supported, and always include the preview surface as the first target.


List<Surface> outputs = new ArrayList<>();
outputs.add(previewSurface);
if (imageReader != null) {
    outputs.add(imageReader.getSurface());
}
mCameraDevice.createCaptureSession(outputs, new CameraCaptureSession.StateCallback() {
    @Override
    public void onConfigured(@NonNull CameraCaptureSession session) {
        mCaptureSession = session;
        updatePreview();
    }
    @Override
    public void onConfigureFailed(@NonNull CameraCaptureSession session) {
        showError("Unable to start camera");
    }
}, mHandler);

Prevent: Add a pre‑flight check that iterates over OutputConfigurations and validates each against the device’s StreamConfigurationMap. Fail fast with an informative message if any configuration is invalid.

ImageReader buffer mismatches

When using an ImageReader to capture still images, developers sometimes mismatch the image format (YUV_420_888 vs JPEG) or forget to set the correct maxImages, leading to IllegalArgumentException or dropped frames.

How it looks: The captured image callback returns null, or the app crashes with Attempt to invoke virtual method 'android.media.Image android.media.ImageReader.acquireLatestImage()' on a null object reference.

Reproduce: Create an ImageReader with maxImages = 1 and try to capture bursts faster than the reader can release images.

Detect: In your OnImageAvailableListener, log the image’s format and timestamp. If image == null for more than two consecutive callbacks, treat it as a buffer starvation symptom.

Fix:


private ImageReader mImageReader;
//...
mImageReader = ImageReader.newInstance(
        imageWidth, imageHeight,
        ImageFormat.JPEG, /* maxImages */ 2);
mImageReader.setOnImageAvailableListener(
        new ImageReader.OnImageAvailableListener() {
            @Override
            public void onImageAvailable(ImageReader reader) {
                Image image = null;
                try {
                    image = reader.acquireLatestImage();
                    // process image...
                } finally {
                    if (image != null) {
                        image.close();
                    }
                }
            }
        }, mBackgroundHandler);

Increase maxImages to at least 2 for burst shooting, and always close the image after use.

Prevent: Write a utility method createImageReader(int width, int height, int format, int maxImages) that validates the format against ImageFormat.JPEG or ImageFormat.YUV_420_888 and ensures maxImages >= 2 when the caller indicates burst mode. Add unit tests that simulate rapid image acquisition and assert no null images are returned.

Torch/flash control

Turning on the torch (FLASH_MODE_TORCH) before the camera is fully initialized can cause the flash to stay on indefinitely, draining the battery and potentially overheating the LED. Conversely, failing to turn off the torch after video capture leaves it active for the next session.

How it looks: The LED remains lit after exiting the camera screen, visible even when the app is backgrounded.

Reproduce: Open the camera, enable torch, press Home, then return to the app and notice the torch still on.

Detect: Use adb shell dumpsys battery to check the flashlight status, or listen for android.hardware.camera2.CameraManager.TorchCallback and assert that torchOff() is called in onPause() or onDestroy().

Fix:


private void setTorch(boolean on) {
    try {
        mCameraManager.setTorchMode(mCameraId, on);
    } catch (CameraAccessException e) {
        Log.e(TAG, "Unable to set torch mode", e);
    }
}

@Override
protected void onPause() {
    super.onPause();
    setTorch(false);
    // ... other cleanup
}

Prevent: Encapsulate torch logic in a TorchManager that registers a TorchCallback to know the actual hardware state and only attempts to change it when the camera is open. Unit‑test the manager with a mock CameraManager that verifies setTorchMode is called with the correct arguments in the right lifecycle order.

Common Camera Integration Bugs and How to Catch Them: Video Recording Glitches

MediaRecorder unprepared states

A frequent mistake is calling mediaRecorder.start() before mediaRecorder.prepare(), or failing to reset the recorder between recordings, leading to IllegalStateException.

How it looks: The app crashes when the user presses the record button, with logcat showing start failed: -2147483648.

Reproduce: Press record, then immediately press stop without changing any settings, then press record again.

Detect: Surround mediaRecorder.start() with a try/catch and log the exception. In automated tests, use a mock MediaRecorder that throws IllegalStateException if start() is called before prepare().

Fix: Follow the exact state machine:


mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE);
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
mediaRecorder.setOutputFile(outputFile);
mediaRecorder.setVideoEncodingBitRate(bitRate);
mediaRecorder.setVideoFrameRate(frameRate);
mediaRecorder.setVideoSize(videoWidth, videoHeight);
mediaRecorder.setOrientationOrientation(rotation);
mediaRecorder.setPreviewDisplay(previewSurface.getSurface());

mediaRecorder.prepare();
mediaRecorder.start();
// ... later
mediaRecorder.stop();
mediaRecorder.reset();

Always call reset() after stop() before reusing the instance.

Prevent: Create a VideoRecorder class that hides the state machine and exposes only startRecording(File) and stopRecording(). Internally, it tracks the current state (IDLE, PREPARED, RECORDING) and throws an IllegalStateException with a helpful message if called out of order. Unit‑test the state transitions.

Audio‑video sync drift

When the video encoder’s timestamp generator drifts relative to the audio encoder, the final MP4 shows lips moving out of sync. This often happens on devices where the audio source is sampled at 44.1 kHz while the video encoder expects 48 kHz, or when the MediaRecorder is configured with a mismatched video frame rate.

How it looks: Playback reveals a gradual delay; after 10 seconds the audio may be ahead by several frames.

Reproduce: Record a 15‑second clip of a person speaking, then play it back and measure the offset using a frame‑accurate tool like ffprobe.

Detect: Extract the audio and video tracks with ffmpeg -i input.mp4 -map 0:a -c copy audio.aac and -map 0:v -c copy video.h264, then compute the presentation timestamps (PTS) difference. A growing delta indicates drift.

Fix: Explicitly set the audio sampling rate and video frame rate to values known to be stable on the target device, e.g.,


mediaRecorder.setAudioSamplingRate(44100);
mediaRecorder.setAudioEncodingBitRate(128000);
mediaRecorder.setVideoFrameRate(30);
mediaRecorder.setVideoEncodingBitRate(2_000_000);

Additionally, enable the MediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264) and setAudioEncoder(MediaRecorder.AudioEncoder.AAC).

Prevent: Add a validation step that checks the device’s AudioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE) and matches it to the recorder’s audio sampling rate. If they differ, log a warning and fallback to the device’s native rate.

File size limits and storage

Writing video to external storage on Android 10+ requires scoped storage permissions, and many apps still attempt to write to /sdcard/Movies/ directly, resulting in SecurityException. Also, failing to check free space before recording can cause the file to be truncated mid‑capture.

How it looks: The recording stops unexpectedly after a few seconds, and a toast says “Unable to save video”.

Reproduce: Fill the device storage to < 100 MB, start a long‑duration video recording, and observe early termination.

Detect: Before calling mediaRecorder.setOutputFile(), check StatFs for available bytes and compare to an estimated maximum size (bitrate * duration / 8).

Fix: Use getExternalFilesDir(Environment.DIRECTORY_MOVIES) to obtain an app‑specific directory that does not require additional permissions, and request MANAGE_EXTERNAL_STORAGE only if you truly need access to the public media store.


File movieDir = new File(context.getExternalFilesDir(
        Environment.DIRECTORY_MOVIES), "myapp");
if (!movieDir.exists()) {
    movieDir.mkdirs();
}
File outputFile = new File(movieDir,
        System.currentTimeMillis() + ".mp4");
mediaRecorder.setOutputFile(outputFile.getAbsolutePath());

Prevent: Write a pre‑flight check that verifies available space > 2 × estimated size, and if not, disables the record button and shows a tooltip suggesting the user free space or change storage location.

Common Camera Integration Bugs and How to Catch Them: Memory and Performance Issues

Bitmap memory leaks

Developers often convert byte[] from ImageReader to a Bitmap using BitmapFactory.decodeByteArray and then hold onto the bitmap in a field or a static cache, causing the heap to grow until OutOfMemoryError.

How it looks: After several captures, the UI becomes sluggish, and eventually the app crashes with a Java heap dump showing large [B and android.graphics.Bitmap objects.

Reproduce: Take 20 photos in rapid succession without recycling the bitmaps.

Detect: Use Android Studio’s Memory Profiler to watch the bitmap count. In unit tests, use LeakCanary to assert that no bitmap remains referenced after the image processing callback finishes.

Fix:


private void processImage(Image image) {
    Image.Plane[] planes = image.getPlanes();
    ByteBuffer buffer = planes[0].getBuffer();
    byte[] data = new byte[buffer.remaining()];
    buffer.get(data);
    Bitmap bmp = BitmapFactory.decodeByteArray(data, 0, data.length);
    // Use bmp (e.g., send to ML model)
    bmp.recycle(); // explicit recycle
    image.close();
}

Avoid storing the bitmap beyond the scope of the method; if you need to keep it, use a WeakReference or an LRU cache with a strict size limit.

Prevent: Adopt a rule that any Bitmap obtained from camera data must be recycled or cleared before the method returns. Enforce this with a custom lint rule that flags any assignment of a Bitmap to a class field unless the field is annotated @Nullable and accompanied by a comment explaining the lifecycle.

Preview frame drop

High‑resolution preview streams can overwhelm the UI thread if each frame is processed (e.g., for barcode scanning) without offloading to a background thread, causing dropped frames and a jerky preview.

How it looks: The preview appears to stutter, and the barcode scanner misses codes that move quickly across the screen.

Reproduce: Point the camera at a moving QR code at 30 fps while performing a heavy image processing operation on the preview callback.

Detect: In the preview callback, log the timestamp difference between consecutive frames. If the inter‑frame gap exceeds 33 ms (for 30 fps) more than 5 % of the time, flag frame drop.

Fix: Offload heavy work to a HandlerThread or ExecutorService. For example,

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.

Try SUSA Free