[camera_android] Add NV21 as an image stream format #3277 (#3639)

This contains the changes for camera_android from https://github.com/flutter/packages/pull/3277
diff --git a/packages/camera/camera_android/CHANGELOG.md b/packages/camera/camera_android/CHANGELOG.md
index 91bd0fb..c1cd97e 100644
--- a/packages/camera/camera_android/CHANGELOG.md
+++ b/packages/camera/camera_android/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.10.7
+
+* Adds support for NV21 as a new streaming format in Android which includes correct handling of
+  image padding when present.
+
 ## 0.10.6+2
 
 * Fixes compatibility with AGP versions older than 4.2.
diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java
index 954458f..9bef240 100644
--- a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java
+++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java
@@ -21,7 +21,6 @@
 import android.hardware.camera2.params.SessionConfiguration;
 import android.media.CamcorderProfile;
 import android.media.EncoderProfiles;
-import android.media.Image;
 import android.media.ImageReader;
 import android.media.MediaRecorder;
 import android.os.Build;
@@ -58,19 +57,18 @@
 import io.flutter.plugins.camera.features.resolution.ResolutionPreset;
 import io.flutter.plugins.camera.features.sensororientation.DeviceOrientationManager;
 import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature;
+import io.flutter.plugins.camera.media.ImageStreamReader;
 import io.flutter.plugins.camera.media.MediaRecorderBuilder;
 import io.flutter.plugins.camera.types.CameraCaptureProperties;
 import io.flutter.plugins.camera.types.CaptureTimeoutsWrapper;
 import io.flutter.view.TextureRegistry.SurfaceTextureEntry;
 import java.io.File;
 import java.io.IOException;
-import java.nio.ByteBuffer;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
-import java.util.Map;
 import java.util.concurrent.Executors;
 
 @FunctionalInterface
@@ -90,6 +88,7 @@
     supportedImageFormats = new HashMap<>();
     supportedImageFormats.put("yuv420", ImageFormat.YUV_420_888);
     supportedImageFormats.put("jpeg", ImageFormat.JPEG);
+    supportedImageFormats.put("nv21", ImageFormat.NV21);
   }
 
   /**
@@ -131,7 +130,7 @@
   CameraDeviceWrapper cameraDevice;
   CameraCaptureSession captureSession;
   private ImageReader pictureImageReader;
-  ImageReader imageStreamReader;
+  ImageStreamReader imageStreamReader;
   /** {@link CaptureRequest.Builder} for the camera preview */
   CaptureRequest.Builder previewRequestBuilder;
 
@@ -306,7 +305,7 @@
       imageFormat = ImageFormat.YUV_420_888;
     }
     imageStreamReader =
-        ImageReader.newInstance(
+        new ImageStreamReader(
             resolutionFeature.getPreviewSize().getWidth(),
             resolutionFeature.getPreviewSize().getHeight(),
             imageFormat,
@@ -536,7 +535,7 @@
       surfaces.add(mediaRecorder.getSurface());
       successCallback = () -> mediaRecorder.start();
     }
-    if (stream) {
+    if (stream && imageStreamReader != null) {
       surfaces.add(imageStreamReader.getSurface());
     }
 
@@ -1191,49 +1190,21 @@
 
           @Override
           public void onCancel(Object o) {
-            imageStreamReader.setOnImageAvailableListener(null, backgroundHandler);
+            if (imageStreamReader == null) {
+              return;
+            }
+
+            imageStreamReader.removeListener(backgroundHandler);
           }
         });
   }
 
   void setImageStreamImageAvailableListener(final EventChannel.EventSink imageStreamSink) {
-    imageStreamReader.setOnImageAvailableListener(
-        reader -> {
-          Image img = reader.acquireNextImage();
-          // Use acquireNextImage since image reader is only for one image.
-          if (img == null) return;
+    if (imageStreamReader == null) {
+      return;
+    }
 
-          List<Map<String, Object>> planes = new ArrayList<>();
-          for (Image.Plane plane : img.getPlanes()) {
-            ByteBuffer buffer = plane.getBuffer();
-
-            byte[] bytes = new byte[buffer.remaining()];
-            buffer.get(bytes, 0, bytes.length);
-
-            Map<String, Object> planeBuffer = new HashMap<>();
-            planeBuffer.put("bytesPerRow", plane.getRowStride());
-            planeBuffer.put("bytesPerPixel", plane.getPixelStride());
-            planeBuffer.put("bytes", bytes);
-
-            planes.add(planeBuffer);
-          }
-
-          Map<String, Object> imageBuffer = new HashMap<>();
-          imageBuffer.put("width", img.getWidth());
-          imageBuffer.put("height", img.getHeight());
-          imageBuffer.put("format", img.getFormat());
-          imageBuffer.put("planes", planes);
-          imageBuffer.put("lensAperture", this.captureProps.getLastLensAperture());
-          imageBuffer.put("sensorExposureTime", this.captureProps.getLastSensorExposureTime());
-          Integer sensorSensitivity = this.captureProps.getLastSensorSensitivity();
-          imageBuffer.put(
-              "sensorSensitivity", sensorSensitivity == null ? null : (double) sensorSensitivity);
-
-          final Handler handler = new Handler(Looper.getMainLooper());
-          handler.post(() -> imageStreamSink.success(imageBuffer));
-          img.close();
-        },
-        backgroundHandler);
+    imageStreamReader.subscribeListener(this.captureProps, imageStreamSink, backgroundHandler);
   }
 
   void closeCaptureSession() {
diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/media/ImageStreamReader.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/media/ImageStreamReader.java
new file mode 100644
index 0000000..1a9cf18
--- /dev/null
+++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/media/ImageStreamReader.java
@@ -0,0 +1,228 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package io.flutter.plugins.camera.media;
+
+import android.graphics.ImageFormat;
+import android.media.Image;
+import android.media.ImageReader;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Surface;
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import io.flutter.plugin.common.EventChannel;
+import io.flutter.plugins.camera.types.CameraCaptureProperties;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+// Wraps an ImageReader to allow for testing of the image handler.
+public class ImageStreamReader {
+
+  /**
+   * The image format we are going to send back to dart. Usually it's the same as streamImageFormat
+   * but in the case of NV21 we will actually request YUV frames but convert it to NV21 before
+   * sending to dart.
+   */
+  private final int dartImageFormat;
+
+  private final ImageReader imageReader;
+  private final ImageStreamReaderUtils imageStreamReaderUtils;
+
+  /**
+   * Creates a new instance of the {@link ImageStreamReader}.
+   *
+   * @param imageReader is the image reader that will receive frames
+   * @param imageStreamReaderUtils is an instance of {@link ImageStreamReaderUtils}
+   */
+  @VisibleForTesting
+  public ImageStreamReader(
+      @NonNull ImageReader imageReader,
+      int dartImageFormat,
+      @NonNull ImageStreamReaderUtils imageStreamReaderUtils) {
+    this.imageReader = imageReader;
+    this.dartImageFormat = dartImageFormat;
+    this.imageStreamReaderUtils = imageStreamReaderUtils;
+  }
+
+  /**
+   * Creates a new instance of the {@link ImageStreamReader}.
+   *
+   * @param width is the image width
+   * @param height is the image height
+   * @param imageFormat is the {@link ImageFormat} that should be returned to dart.
+   * @param maxImages is how many images can be acquired at one time, usually 1.
+   */
+  public ImageStreamReader(int width, int height, int imageFormat, int maxImages) {
+    this.dartImageFormat = imageFormat;
+    this.imageReader =
+        ImageReader.newInstance(width, height, computeStreamImageFormat(imageFormat), maxImages);
+    this.imageStreamReaderUtils = new ImageStreamReaderUtils();
+  }
+
+  /**
+   * Returns the image format to stream based on a requested input format. Usually it's the same
+   * except when dart is requesting NV21. In that case we stream YUV420 and process it into NV21
+   * before sending the frames over.
+   *
+   * @param dartImageFormat is the image format dart is requesting.
+   * @return the image format that should be streamed from the camera.
+   */
+  @VisibleForTesting
+  public static int computeStreamImageFormat(int dartImageFormat) {
+    if (dartImageFormat == ImageFormat.NV21) {
+      return ImageFormat.YUV_420_888;
+    } else {
+      return dartImageFormat;
+    }
+  }
+
+  /**
+   * Processes a new frame (image) from the image reader and send the frame to Dart.
+   *
+   * @param image is the image which needs processed as an {@link Image}
+   * @param captureProps is the capture props from the camera class as {@link
+   *     CameraCaptureProperties}
+   * @param imageStreamSink is the image stream sink from dart as a dart {@link
+   *     EventChannel.EventSink}
+   */
+  @VisibleForTesting
+  public void onImageAvailable(
+      @NonNull Image image,
+      @NonNull CameraCaptureProperties captureProps,
+      @NonNull EventChannel.EventSink imageStreamSink) {
+    try {
+      Map<String, Object> imageBuffer = new HashMap<>();
+
+      // Get plane data ready
+      if (dartImageFormat == ImageFormat.NV21) {
+        imageBuffer.put("planes", parsePlanesForNv21(image));
+      } else {
+        imageBuffer.put("planes", parsePlanesForYuvOrJpeg(image));
+      }
+
+      imageBuffer.put("width", image.getWidth());
+      imageBuffer.put("height", image.getHeight());
+      imageBuffer.put("format", dartImageFormat);
+      imageBuffer.put("lensAperture", captureProps.getLastLensAperture());
+      imageBuffer.put("sensorExposureTime", captureProps.getLastSensorExposureTime());
+      Integer sensorSensitivity = captureProps.getLastSensorSensitivity();
+      imageBuffer.put(
+          "sensorSensitivity", sensorSensitivity == null ? null : (double) sensorSensitivity);
+
+      final Handler handler = new Handler(Looper.getMainLooper());
+      handler.post(() -> imageStreamSink.success(imageBuffer));
+      image.close();
+
+    } catch (IllegalStateException e) {
+      // Handle "buffer is inaccessible" errors that can happen on some devices from ImageStreamReaderUtils.yuv420ThreePlanesToNV21()
+      final Handler handler = new Handler(Looper.getMainLooper());
+      handler.post(
+          () ->
+              imageStreamSink.error(
+                  "IllegalStateException",
+                  "Caught IllegalStateException: " + e.getMessage(),
+                  null));
+      image.close();
+    }
+  }
+
+  /**
+   * Given an input image, will return a list of maps suitable to send back to dart where each map
+   * describes the image plane.
+   *
+   * <p>For Yuv / Jpeg, we do no further processing on the frame so we simply send it as-is.
+   *
+   * @param image - the image to process.
+   * @return parsed map describing the image planes to be sent to dart.
+   */
+  @NonNull
+  public List<Map<String, Object>> parsePlanesForYuvOrJpeg(@NonNull Image image) {
+    List<Map<String, Object>> planes = new ArrayList<>();
+
+    // For YUV420 and JPEG, just send the data as-is for each plane.
+    for (Image.Plane plane : image.getPlanes()) {
+      ByteBuffer buffer = plane.getBuffer();
+
+      byte[] bytes = new byte[buffer.remaining()];
+      buffer.get(bytes, 0, bytes.length);
+
+      Map<String, Object> planeBuffer = new HashMap<>();
+      planeBuffer.put("bytesPerRow", plane.getRowStride());
+      planeBuffer.put("bytesPerPixel", plane.getPixelStride());
+      planeBuffer.put("bytes", bytes);
+
+      planes.add(planeBuffer);
+    }
+    return planes;
+  }
+
+  /**
+   * Given an input image, will return a single-plane NV21 image. Assumes YUV420 as an input type.
+   *
+   * @param image - the image to process.
+   * @return parsed map describing the image planes to be sent to dart.
+   */
+  @NonNull
+  public List<Map<String, Object>> parsePlanesForNv21(@NonNull Image image) {
+    List<Map<String, Object>> planes = new ArrayList<>();
+
+    // We will convert the YUV data to NV21 which is a single-plane image
+    ByteBuffer bytes =
+        imageStreamReaderUtils.yuv420ThreePlanesToNV21(
+            image.getPlanes(), image.getWidth(), image.getHeight());
+
+    Map<String, Object> planeBuffer = new HashMap<>();
+    planeBuffer.put("bytesPerRow", image.getWidth());
+    planeBuffer.put("bytesPerPixel", 1);
+    planeBuffer.put("bytes", bytes.array());
+    planes.add(planeBuffer);
+    return planes;
+  }
+
+  /** Returns the image reader surface. */
+  @NonNull
+  public Surface getSurface() {
+    return imageReader.getSurface();
+  }
+
+  /**
+   * Subscribes the image stream reader to handle incoming images using onImageAvailable().
+   *
+   * @param captureProps is the capture props from the camera class as {@link
+   *     CameraCaptureProperties}
+   * @param imageStreamSink is the image stream sink from dart as {@link EventChannel.EventSink}
+   * @param handler is generally the background handler of the camera as {@link Handler}
+   */
+  public void subscribeListener(
+      @NonNull CameraCaptureProperties captureProps,
+      @NonNull EventChannel.EventSink imageStreamSink,
+      @NonNull Handler handler) {
+    imageReader.setOnImageAvailableListener(
+        reader -> {
+          Image image = reader.acquireNextImage();
+          if (image == null) return;
+
+          onImageAvailable(image, captureProps, imageStreamSink);
+        },
+        handler);
+  }
+
+  /**
+   * Removes the listener from the image reader.
+   *
+   * @param handler is generally the background handler of the camera
+   */
+  public void removeListener(@NonNull Handler handler) {
+    imageReader.setOnImageAvailableListener(null, handler);
+  }
+
+  /** Closes the image reader. */
+  public void close() {
+    imageReader.close();
+  }
+}
diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/media/ImageStreamReaderUtils.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/media/ImageStreamReaderUtils.java
new file mode 100644
index 0000000..d56ee8b
--- /dev/null
+++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/media/ImageStreamReaderUtils.java
@@ -0,0 +1,154 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// Note: the code in this file is taken directly from the official Google MLKit example:
+// https://github.com/googlesamples/mlkit
+
+package io.flutter.plugins.camera.media;
+
+import android.media.Image;
+import androidx.annotation.NonNull;
+import java.nio.ByteBuffer;
+
+public class ImageStreamReaderUtils {
+  /**
+   * Converts YUV_420_888 to NV21 bytebuffer.
+   *
+   * <p>The NV21 format consists of a single byte array containing the Y, U and V values. For an
+   * image of size S, the first S positions of the array contain all the Y values. The remaining
+   * positions contain interleaved V and U values. U and V are subsampled by a factor of 2 in both
+   * dimensions, so there are S/4 U values and S/4 V values. In summary, the NV21 array will contain
+   * S Y values followed by S/4 VU values: YYYYYYYYYYYYYY(...)YVUVUVUVU(...)VU
+   *
+   * <p>YUV_420_888 is a generic format that can describe any YUV image where U and V are subsampled
+   * by a factor of 2 in both dimensions. {@link Image#getPlanes} returns an array with the Y, U and
+   * V planes. The Y plane is guaranteed not to be interleaved, so we can just copy its values into
+   * the first part of the NV21 array. The U and V planes may already have the representation in the
+   * NV21 format. This happens if the planes share the same buffer, the V buffer is one position
+   * before the U buffer and the planes have a pixelStride of 2. If this is case, we can just copy
+   * them to the NV21 array.
+   *
+   * <p>https://github.com/googlesamples/mlkit/blob/master/android/vision-quickstart/app/src/main/java/com/google/mlkit/vision/demo/BitmapUtils.java
+   */
+  @NonNull
+  public ByteBuffer yuv420ThreePlanesToNV21(
+      @NonNull Image.Plane[] yuv420888planes, int width, int height) {
+    int imageSize = width * height;
+    byte[] out = new byte[imageSize + 2 * (imageSize / 4)];
+
+    if (areUVPlanesNV21(yuv420888planes, width, height)) {
+      // Copy the Y values.
+      yuv420888planes[0].getBuffer().get(out, 0, imageSize);
+
+      ByteBuffer uBuffer = yuv420888planes[1].getBuffer();
+      ByteBuffer vBuffer = yuv420888planes[2].getBuffer();
+      // Get the first V value from the V buffer, since the U buffer does not contain it.
+      vBuffer.get(out, imageSize, 1);
+      // Copy the first U value and the remaining VU values from the U buffer.
+      uBuffer.get(out, imageSize + 1, 2 * imageSize / 4 - 1);
+    } else {
+      // Fallback to copying the UV values one by one, which is slower but also works.
+      // Unpack Y.
+      unpackPlane(yuv420888planes[0], width, height, out, 0, 1);
+      // Unpack U.
+      unpackPlane(yuv420888planes[1], width, height, out, imageSize + 1, 2);
+      // Unpack V.
+      unpackPlane(yuv420888planes[2], width, height, out, imageSize, 2);
+    }
+
+    return ByteBuffer.wrap(out);
+  }
+
+  /**
+   * Copyright 2020 Google LLC. All rights reserved.
+   *
+   * <p>Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+   * except in compliance with the License. You may obtain a copy of the License at
+   *
+   * <p>http://www.apache.org/licenses/LICENSE-2.0
+   *
+   * <p>Unless required by applicable law or agreed to in writing, software distributed under the
+   * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+   * either express or implied. See the License for the specific language governing permissions and
+   * limitations under the License.
+   *
+   * <p>Checks if the UV plane buffers of a YUV_420_888 image are in the NV21 format.
+   *
+   * <p>https://github.com/googlesamples/mlkit/blob/master/android/vision-quickstart/app/src/main/java/com/google/mlkit/vision/demo/BitmapUtils.java
+   */
+  private static boolean areUVPlanesNV21(@NonNull Image.Plane[] planes, int width, int height) {
+    int imageSize = width * height;
+
+    ByteBuffer uBuffer = planes[1].getBuffer();
+    ByteBuffer vBuffer = planes[2].getBuffer();
+
+    // Backup buffer properties.
+    int vBufferPosition = vBuffer.position();
+    int uBufferLimit = uBuffer.limit();
+
+    // Advance the V buffer by 1 byte, since the U buffer will not contain the first V value.
+    vBuffer.position(vBufferPosition + 1);
+    // Chop off the last byte of the U buffer, since the V buffer will not contain the last U value.
+    uBuffer.limit(uBufferLimit - 1);
+
+    // Check that the buffers are equal and have the expected number of elements.
+    boolean areNV21 =
+        (vBuffer.remaining() == (2 * imageSize / 4 - 2)) && (vBuffer.compareTo(uBuffer) == 0);
+
+    // Restore buffers to their initial state.
+    vBuffer.position(vBufferPosition);
+    uBuffer.limit(uBufferLimit);
+
+    return areNV21;
+  }
+
+  /**
+   * Copyright 2020 Google LLC. All rights reserved.
+   *
+   * <p>Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+   * except in compliance with the License. You may obtain a copy of the License at
+   *
+   * <p>http://www.apache.org/licenses/LICENSE-2.0
+   *
+   * <p>Unless required by applicable law or agreed to in writing, software distributed under the
+   * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+   * either express or implied. See the License for the specific language governing permissions and
+   * limitations under the License.
+   *
+   * <p>Unpack an image plane into a byte array.
+   *
+   * <p>The input plane data will be copied in 'out', starting at 'offset' and every pixel will be
+   * spaced by 'pixelStride'. Note that there is no row padding on the output.
+   *
+   * <p>https://github.com/googlesamples/mlkit/blob/master/android/vision-quickstart/app/src/main/java/com/google/mlkit/vision/demo/BitmapUtils.java
+   */
+  private static void unpackPlane(
+      @NonNull Image.Plane plane, int width, int height, byte[] out, int offset, int pixelStride)
+      throws IllegalStateException {
+    ByteBuffer buffer = plane.getBuffer();
+    buffer.rewind();
+
+    // Compute the size of the current plane.
+    // We assume that it has the aspect ratio as the original image.
+    int numRow = (buffer.limit() + plane.getRowStride() - 1) / plane.getRowStride();
+    if (numRow == 0) {
+      return;
+    }
+    int scaleFactor = height / numRow;
+    int numCol = width / scaleFactor;
+
+    // Extract the data in the output buffer.
+    int outputPos = offset;
+    int rowStart = 0;
+    for (int row = 0; row < numRow; row++) {
+      int inputPos = rowStart;
+      for (int col = 0; col < numCol; col++) {
+        out[outputPos] = buffer.get(inputPos);
+        outputPos += pixelStride;
+        inputPos += plane.getPixelStride();
+      }
+      rowStart += plane.getRowStride();
+    }
+  }
+}
diff --git a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/media/ImageStreamReaderTest.java b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/media/ImageStreamReaderTest.java
new file mode 100644
index 0000000..22e7aab
--- /dev/null
+++ b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/media/ImageStreamReaderTest.java
@@ -0,0 +1,165 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package io.flutter.plugins.camera.media;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.ImageFormat;
+import android.media.Image;
+import android.media.ImageReader;
+import io.flutter.plugin.common.EventChannel;
+import io.flutter.plugins.camera.types.CameraCaptureProperties;
+import java.nio.ByteBuffer;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class ImageStreamReaderTest {
+  /** If we request YUV42 we should stream in YUV420. */
+  @Test
+  public void computeStreamImageFormat_computesCorrectStreamFormatYuv() {
+    int requestedStreamFormat = ImageFormat.YUV_420_888;
+    int result = ImageStreamReader.computeStreamImageFormat(requestedStreamFormat);
+    assertEquals(result, ImageFormat.YUV_420_888);
+  }
+
+  /**
+   * When we want to stream in NV21, we should still request YUV420 from the camera because we will
+   * convert it to NV21 before sending it to dart.
+   */
+  @Test
+  public void computeStreamImageFormat_computesCorrectStreamFormatNv21() {
+    int requestedStreamFormat = ImageFormat.NV21;
+    int result = ImageStreamReader.computeStreamImageFormat(requestedStreamFormat);
+    assertEquals(result, ImageFormat.YUV_420_888);
+  }
+
+  /**
+   * If we are requesting NV21, then the planes should be processed and converted to NV21 before
+   * being sent to dart. We make sure yuv420ThreePlanesToNV21 is called when we are requesting
+   */
+  @Test
+  public void onImageAvailable_parsesPlanesForNv21() {
+    // Dart wants NV21 frames
+    int dartImageFormat = ImageFormat.NV21;
+
+    ImageReader mockImageReader = mock(ImageReader.class);
+    ImageStreamReaderUtils mockImageStreamReaderUtils = mock(ImageStreamReaderUtils.class);
+    ImageStreamReader imageStreamReader =
+        new ImageStreamReader(mockImageReader, dartImageFormat, mockImageStreamReaderUtils);
+
+    ByteBuffer mockBytes = ByteBuffer.allocate(0);
+    when(mockImageStreamReaderUtils.yuv420ThreePlanesToNV21(any(), anyInt(), anyInt()))
+        .thenReturn(mockBytes);
+
+    // The image format as streamed from the camera
+    int imageFormat = ImageFormat.YUV_420_888;
+
+    // Mock YUV image
+    Image mockImage = mock(Image.class);
+    when(mockImage.getWidth()).thenReturn(1280);
+    when(mockImage.getHeight()).thenReturn(720);
+    when(mockImage.getFormat()).thenReturn(imageFormat);
+
+    // Mock planes. YUV images have 3 planes (Y, U, V).
+    Image.Plane planeY = mock(Image.Plane.class);
+    Image.Plane planeU = mock(Image.Plane.class);
+    Image.Plane planeV = mock(Image.Plane.class);
+
+    // Y plane is width*height
+    // Row stride is generally == width but when there is padding it will
+    // be larger. The numbers in this example are from a Vivo V2135 on 'high'
+    // setting (1280x720).
+    when(planeY.getBuffer()).thenReturn(ByteBuffer.allocate(1105664));
+    when(planeY.getRowStride()).thenReturn(1536);
+    when(planeY.getPixelStride()).thenReturn(1);
+
+    // U and V planes are always the same sizes/values.
+    // https://developer.android.com/reference/android/graphics/ImageFormat#YUV_420_888
+    when(planeU.getBuffer()).thenReturn(ByteBuffer.allocate(552703));
+    when(planeV.getBuffer()).thenReturn(ByteBuffer.allocate(552703));
+    when(planeU.getRowStride()).thenReturn(1536);
+    when(planeV.getRowStride()).thenReturn(1536);
+    when(planeU.getPixelStride()).thenReturn(2);
+    when(planeV.getPixelStride()).thenReturn(2);
+
+    // Add planes to image
+    Image.Plane[] planes = {planeY, planeU, planeV};
+    when(mockImage.getPlanes()).thenReturn(planes);
+
+    CameraCaptureProperties mockCaptureProps = mock(CameraCaptureProperties.class);
+    EventChannel.EventSink mockEventSink = mock(EventChannel.EventSink.class);
+    imageStreamReader.onImageAvailable(mockImage, mockCaptureProps, mockEventSink);
+
+    // Make sure we processed the frame with parsePlanesForNv21
+    verify(mockImageStreamReaderUtils)
+        .yuv420ThreePlanesToNV21(planes, mockImage.getWidth(), mockImage.getHeight());
+  }
+
+  /** If we are requesting YUV420, then we should send the 3-plane image as it is. */
+  @Test
+  public void onImageAvailable_parsesPlanesForYuv420() {
+    // Dart wants NV21 frames
+    int dartImageFormat = ImageFormat.YUV_420_888;
+
+    ImageReader mockImageReader = mock(ImageReader.class);
+    ImageStreamReaderUtils mockImageStreamReaderUtils = mock(ImageStreamReaderUtils.class);
+    ImageStreamReader imageStreamReader =
+        new ImageStreamReader(mockImageReader, dartImageFormat, mockImageStreamReaderUtils);
+
+    ByteBuffer mockBytes = ByteBuffer.allocate(0);
+    when(mockImageStreamReaderUtils.yuv420ThreePlanesToNV21(any(), anyInt(), anyInt()))
+        .thenReturn(mockBytes);
+
+    // The image format as streamed from the camera
+    int imageFormat = ImageFormat.YUV_420_888;
+
+    // Mock YUV image
+    Image mockImage = mock(Image.class);
+    when(mockImage.getWidth()).thenReturn(1280);
+    when(mockImage.getHeight()).thenReturn(720);
+    when(mockImage.getFormat()).thenReturn(imageFormat);
+
+    // Mock planes. YUV images have 3 planes (Y, U, V).
+    Image.Plane planeY = mock(Image.Plane.class);
+    Image.Plane planeU = mock(Image.Plane.class);
+    Image.Plane planeV = mock(Image.Plane.class);
+
+    // Y plane is width*height
+    // Row stride is generally == width but when there is padding it will
+    // be larger. The numbers in this example are from a Vivo V2135 on 'high'
+    // setting (1280x720).
+    when(planeY.getBuffer()).thenReturn(ByteBuffer.allocate(1105664));
+    when(planeY.getRowStride()).thenReturn(1536);
+    when(planeY.getPixelStride()).thenReturn(1);
+
+    // U and V planes are always the same sizes/values.
+    // https://developer.android.com/reference/android/graphics/ImageFormat#YUV_420_888
+    when(planeU.getBuffer()).thenReturn(ByteBuffer.allocate(552703));
+    when(planeV.getBuffer()).thenReturn(ByteBuffer.allocate(552703));
+    when(planeU.getRowStride()).thenReturn(1536);
+    when(planeV.getRowStride()).thenReturn(1536);
+    when(planeU.getPixelStride()).thenReturn(2);
+    when(planeV.getPixelStride()).thenReturn(2);
+
+    // Add planes to image
+    Image.Plane[] planes = {planeY, planeU, planeV};
+    when(mockImage.getPlanes()).thenReturn(planes);
+
+    CameraCaptureProperties mockCaptureProps = mock(CameraCaptureProperties.class);
+    EventChannel.EventSink mockEventSink = mock(EventChannel.EventSink.class);
+    imageStreamReader.onImageAvailable(mockImage, mockCaptureProps, mockEventSink);
+
+    // Make sure we processed the frame with parsePlanesForYuvOrJpeg
+    verify(mockImageStreamReaderUtils, never()).yuv420ThreePlanesToNV21(any(), anyInt(), anyInt());
+  }
+}
diff --git a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/media/ImageStreamReaderUtilsTest.java b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/media/ImageStreamReaderUtilsTest.java
new file mode 100644
index 0000000..ca9a4a4
--- /dev/null
+++ b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/media/ImageStreamReaderUtilsTest.java
@@ -0,0 +1,99 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package io.flutter.plugins.camera.media;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.graphics.ImageFormat;
+import android.media.Image;
+import java.nio.ByteBuffer;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class ImageStreamReaderUtilsTest {
+  private ImageStreamReaderUtils imageStreamReaderUtils;
+
+  @Before
+  public void setUp() {
+    this.imageStreamReaderUtils = new ImageStreamReaderUtils();
+  }
+
+  Image getImage(int imageWidth, int imageHeight, int padding) {
+    int rowStride = imageWidth + padding;
+
+    int ySize = (rowStride * imageHeight) - padding;
+    int uSize = (ySize / 2) - (padding / 2);
+    int vSize = uSize;
+
+    // Mock YUV image
+    Image mockImage = mock(Image.class);
+    when(mockImage.getWidth()).thenReturn(imageWidth);
+    when(mockImage.getHeight()).thenReturn(imageHeight);
+    when(mockImage.getFormat()).thenReturn(ImageFormat.YUV_420_888);
+
+    // Mock planes. YUV images have 3 planes (Y, U, V).
+    Image.Plane planeY = mock(Image.Plane.class);
+    Image.Plane planeU = mock(Image.Plane.class);
+    Image.Plane planeV = mock(Image.Plane.class);
+
+    // Y plane is width*height
+    // Row stride is generally == width but when there is padding it will
+    // be larger.
+    // Here we are adding 256 padding.
+    when(planeY.getBuffer()).thenReturn(ByteBuffer.allocate(ySize));
+    when(planeY.getRowStride()).thenReturn(rowStride);
+    when(planeY.getPixelStride()).thenReturn(1);
+
+    // U and V planes are always the same sizes/values.
+    // https://developer.android.com/reference/android/graphics/ImageFormat#YUV_420_888
+    when(planeU.getBuffer()).thenReturn(ByteBuffer.allocate(uSize));
+    when(planeV.getBuffer()).thenReturn(ByteBuffer.allocate(vSize));
+    when(planeU.getRowStride()).thenReturn(rowStride);
+    when(planeV.getRowStride()).thenReturn(rowStride);
+    when(planeU.getPixelStride()).thenReturn(2);
+    when(planeV.getPixelStride()).thenReturn(2);
+
+    // Add planes to image
+    Image.Plane[] planes = {planeY, planeU, planeV};
+    when(mockImage.getPlanes()).thenReturn(planes);
+
+    return mockImage;
+  }
+
+  /** Ensure that passing in an image with padding returns one without padding */
+  @Test
+  public void yuv420ThreePlanesToNV21_trimsPaddingWhenPresent() {
+    Image mockImage = getImage(160, 120, 16);
+    int imageWidth = mockImage.getWidth();
+    int imageHeight = mockImage.getHeight();
+
+    ByteBuffer result =
+        imageStreamReaderUtils.yuv420ThreePlanesToNV21(
+            mockImage.getPlanes(), mockImage.getWidth(), mockImage.getHeight());
+    Assert.assertEquals(
+        ((long) imageWidth * imageHeight) + (2 * ((long) (imageWidth / 2) * (imageHeight / 2))),
+        result.limit());
+  }
+
+  /** Ensure that passing in an image without padding returns the same size */
+  @Test
+  public void yuv420ThreePlanesToNV21_trimsPaddingWhenAbsent() {
+    Image mockImage = getImage(160, 120, 0);
+    int imageWidth = mockImage.getWidth();
+    int imageHeight = mockImage.getHeight();
+
+    ByteBuffer result =
+        imageStreamReaderUtils.yuv420ThreePlanesToNV21(
+            mockImage.getPlanes(), mockImage.getWidth(), mockImage.getHeight());
+    Assert.assertEquals(
+        ((long) imageWidth * imageHeight) + (2 * ((long) (imageWidth / 2) * (imageHeight / 2))),
+        result.limit());
+  }
+}
diff --git a/packages/camera/camera_android/example/pubspec.yaml b/packages/camera/camera_android/example/pubspec.yaml
index 79c7be1..39f4066 100644
--- a/packages/camera/camera_android/example/pubspec.yaml
+++ b/packages/camera/camera_android/example/pubspec.yaml
@@ -14,7 +14,7 @@
     # The example app is bundled with the plugin so we use a path dependency on
     # the parent directory to use the current plugin's version.
     path: ../
-  camera_platform_interface: ^2.4.0
+  camera_platform_interface: ^2.5.0
   flutter:
     sdk: flutter
   path_provider: ^2.0.0
@@ -33,3 +33,7 @@
 flutter:
   uses-material-design: true
 
+# FOR TESTING ONLY. DO NOT MERGE.
+dependency_overrides:
+  camera_android:
+    path: ../../../camera/camera_android
diff --git a/packages/camera/camera_android/lib/src/type_conversion.dart b/packages/camera/camera_android/lib/src/type_conversion.dart
index 754a5a0..7691f8e 100644
--- a/packages/camera/camera_android/lib/src/type_conversion.dart
+++ b/packages/camera/camera_android/lib/src/type_conversion.dart
@@ -34,6 +34,8 @@
       return ImageFormatGroup.yuv420;
     case 256: // android.graphics.ImageFormat.JPEG
       return ImageFormatGroup.jpeg;
+    case 17: // android.graphics.ImageFormat.NV21
+      return ImageFormatGroup.nv21;
   }
 
   return ImageFormatGroup.unknown;
diff --git a/packages/camera/camera_android/pubspec.yaml b/packages/camera/camera_android/pubspec.yaml
index 9c4fc57..db7835f 100644
--- a/packages/camera/camera_android/pubspec.yaml
+++ b/packages/camera/camera_android/pubspec.yaml
@@ -2,7 +2,7 @@
 description: Android implementation of the camera plugin.
 repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_android
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22
-version: 0.10.6+2
+version: 0.10.7
 
 environment:
   sdk: ">=2.17.0 <4.0.0"
@@ -18,7 +18,7 @@
         dartPluginClass: AndroidCamera
 
 dependencies:
-  camera_platform_interface: ^2.4.0
+  camera_platform_interface: ^2.5.0
   flutter:
     sdk: flutter
   flutter_plugin_android_lifecycle: ^2.0.2
diff --git a/packages/camera/camera_android/test/type_conversion_test.dart b/packages/camera/camera_android/test/type_conversion_test.dart
index b07466d..247fe18 100644
--- a/packages/camera/camera_android/test/type_conversion_test.dart
+++ b/packages/camera/camera_android/test/type_conversion_test.dart
@@ -57,4 +57,26 @@
     });
     expect(cameraImage.format.group, ImageFormatGroup.yuv420);
   });
+
+  test('CameraImageData has ImageFormatGroup.nv21', () {
+    final CameraImageData cameraImage =
+        cameraImageFromPlatformData(<dynamic, dynamic>{
+      'format': 17,
+      'height': 1,
+      'width': 4,
+      'lensAperture': 1.8,
+      'sensorExposureTime': 9991324,
+      'sensorSensitivity': 92.0,
+      'planes': <dynamic>[
+        <dynamic, dynamic>{
+          'bytes': Uint8List.fromList(<int>[1, 2, 3, 4]),
+          'bytesPerPixel': 1,
+          'bytesPerRow': 4,
+          'height': 1,
+          'width': 4
+        }
+      ]
+    });
+    expect(cameraImage.format.group, ImageFormatGroup.nv21);
+  });
 }