diff --git a/packages/camera/camera_android_camerax/CHANGELOG.md b/packages/camera/camera_android_camerax/CHANGELOG.md index dc1f168e948..c893ad571ab 100644 --- a/packages/camera/camera_android_camerax/CHANGELOG.md +++ b/packages/camera/camera_android_camerax/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.24 + +* Fixes `IllegalArgumentException` that could occur during image streaming. + ## 0.6.23 * Converts NV21-compatible streamed images to NV21 when requested. In doing so, diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageProxyUtils.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageProxyUtils.java index 0d062c8397d..90b5461ac88 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageProxyUtils.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageProxyUtils.java @@ -18,55 +18,66 @@ public class ImageProxyUtils { */ @NonNull public static ByteBuffer planesToNV21(@NonNull List planes, int width, int height) { - if (!areUVPlanesNV21(planes, width, height)) { + if (planes.size() < 3) { throw new IllegalArgumentException( - "Provided UV planes are not in NV21 layout and thus cannot be converted."); + "The plane list must contain at least 3 planes (Y, U, V)."); } - int imageSize = width * height; - int nv21Size = imageSize + 2 * (imageSize / 4); - byte[] nv21Bytes = new byte[nv21Size]; + PlaneProxy yPlane = planes.get(0); + PlaneProxy uPlane = planes.get(1); + PlaneProxy vPlane = planes.get(2); - // Copy Y plane. - ByteBuffer yBuffer = planes.get(0).getBuffer(); - yBuffer.rewind(); - yBuffer.get(nv21Bytes, 0, imageSize); - - // Copy interleaved VU plane (NV21 layout). - ByteBuffer vBuffer = planes.get(2).getBuffer(); - ByteBuffer uBuffer = planes.get(1).getBuffer(); + ByteBuffer yBuffer = yPlane.getBuffer(); + ByteBuffer uBuffer = uPlane.getBuffer(); + ByteBuffer vBuffer = vPlane.getBuffer(); - vBuffer.rewind(); + // Rewind buffers to start to ensure full read. + yBuffer.rewind(); uBuffer.rewind(); - vBuffer.get(nv21Bytes, imageSize, 1); - uBuffer.get(nv21Bytes, imageSize + 1, 2 * imageSize / 4 - 1); - - return ByteBuffer.wrap(nv21Bytes); - } + vBuffer.rewind(); - public static boolean areUVPlanesNV21(@NonNull List planes, int width, int height) { - int imageSize = width * height; + int ySize = yBuffer.remaining(); + byte[] nv21Bytes = new byte[ySize + (width * height / 2)]; + int position = 0; + + int yRowStride = yPlane.getRowStride(); + if (yRowStride == width) { + // If no padding, copy entire Y plane at once. + yBuffer.get(nv21Bytes, 0, ySize); + position = ySize; + } else { + // Copy row by row if padding exists. + for (int row = 0; row < height; row++) { + yBuffer.get(nv21Bytes, position, width); + position += width; + if (row < height - 1) { + yBuffer.position(yBuffer.position() - width + yRowStride); + } + } + } - ByteBuffer uBuffer = planes.get(1).getBuffer(); - ByteBuffer vBuffer = planes.get(2).getBuffer(); + int uRowStride = uPlane.getRowStride(); + int vRowStride = vPlane.getRowStride(); + int uPixelStride = uPlane.getPixelStride(); + int vPixelStride = vPlane.getPixelStride(); - // Backup buffer properties. - int vBufferPosition = vBuffer.position(); - int uBufferLimit = uBuffer.limit(); + byte[] uRowBuffer = new byte[uRowStride]; + byte[] vRowBuffer = new byte[vRowStride]; - // 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); + for (int row = 0; row < height / 2; row++) { + // Read full row from U and V planes into temporary buffers. + uBuffer.get(uRowBuffer, 0, Math.min(uBuffer.remaining(), uRowStride)); + vBuffer.get(vRowBuffer, 0, Math.min(vBuffer.remaining(), vRowStride)); - // 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); + for (int col = 0; col < width / 2; col++) { + int vPixelIndex = col * vPixelStride; + int uPixelIndex = col * uPixelStride; - // Restore buffers to their initial state. - vBuffer.position(vBufferPosition); - uBuffer.limit(uBufferLimit); + nv21Bytes[position++] = vRowBuffer[vPixelIndex]; // V (Cr) + nv21Bytes[position++] = uRowBuffer[uPixelIndex]; // U (Cb) + } + } - return areNV21; + return ByteBuffer.wrap(nv21Bytes); } } diff --git a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageProxyUtilsTest.java b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageProxyUtilsTest.java index ad87703059d..919f9131774 100644 --- a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageProxyUtilsTest.java +++ b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageProxyUtilsTest.java @@ -5,9 +5,13 @@ package io.flutter.plugins.camerax; import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import androidx.camera.core.ImageProxy.PlaneProxy; +import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.util.Arrays; import java.util.List; @@ -35,18 +39,19 @@ public void planesToNV21_throwsExceptionForNonNV21Layout() { List planes = Arrays.asList(yPlane, uPlane, vPlane); assertThrows( - IllegalArgumentException.class, () -> ImageProxyUtils.planesToNV21(planes, width, height)); + BufferUnderflowException.class, () -> ImageProxyUtils.planesToNV21(planes, width, height)); } @Test public void planesToNV21_returnsExpectedBufferWhenPlanesAreNV21Compatible() { int width = 4; int height = 2; - int imageSize = width * height; // 8 // Y plane. byte[] y = new byte[] {0, 1, 2, 3, 4, 5, 6, 7}; PlaneProxy yPlane = mockPlaneProxyWithData(y); + when(yPlane.getPixelStride()).thenReturn(1); + when(yPlane.getRowStride()).thenReturn(width); // U and V planes in NV21 format. Both have 2 bytes that are overlapping (5, 7). ByteBuffer vBuffer = ByteBuffer.wrap(new byte[] {9, 5, 7}); @@ -58,6 +63,12 @@ public void planesToNV21_returnsExpectedBufferWhenPlanesAreNV21Compatible() { Mockito.when(uPlane.getBuffer()).thenReturn(uBuffer); Mockito.when(vPlane.getBuffer()).thenReturn(vBuffer); + // Set pixelStride and rowStride for UV planes to trigger NV21 shortcut + Mockito.when(uPlane.getPixelStride()).thenReturn(2); + Mockito.when(uPlane.getRowStride()).thenReturn(width); + Mockito.when(vPlane.getPixelStride()).thenReturn(2); + Mockito.when(vPlane.getRowStride()).thenReturn(width); + List planes = Arrays.asList(yPlane, uPlane, vPlane); ByteBuffer nv21Buffer = ImageProxyUtils.planesToNV21(planes, width, height); @@ -87,11 +98,48 @@ public void planesToNV21_returnsExpectedBufferWhenPlanesAreNV21Compatible() { assertArrayEquals(expected, nv21); } + @Test + public void areUVPlanesNV21_handlesVBufferAtLimitGracefully() { + int width = 1280; + int height = 720; + + // --- Mock Y plane --- + byte[] yData = new byte[width * height]; + PlaneProxy yPlane = mock(PlaneProxy.class); + ByteBuffer yBuffer = ByteBuffer.wrap(yData); + when(yPlane.getBuffer()).thenReturn(yBuffer); + when(yPlane.getPixelStride()).thenReturn(1); + when(yPlane.getRowStride()).thenReturn(width); + + // --- Mock U plane --- + ByteBuffer uBuffer = ByteBuffer.allocate(width * height / 4); + PlaneProxy uPlane = mock(PlaneProxy.class); + when(uPlane.getBuffer()).thenReturn(uBuffer); + when(uPlane.getPixelStride()).thenReturn(1); + when(uPlane.getRowStride()).thenReturn(width / 2); + + // --- Mock V plane --- + ByteBuffer vBuffer = ByteBuffer.allocate(width * height / 4); + vBuffer.position(vBuffer.limit()); // position == limit + PlaneProxy vPlane = mock(PlaneProxy.class); + when(vPlane.getBuffer()).thenReturn(vBuffer); + when(vPlane.getPixelStride()).thenReturn(1); + when(vPlane.getRowStride()).thenReturn(width / 2); + + List planes = Arrays.asList(yPlane, uPlane, vPlane); + + ByteBuffer nv21Buffer = ImageProxyUtils.planesToNV21(planes, width, height); + byte[] nv21 = new byte[nv21Buffer.remaining()]; + nv21Buffer.get(nv21); + + assertEquals(width * height + (width * height / 2), nv21.length); + } + // Creates a mock PlaneProxy with a buffer (of zeroes) of the given size. private PlaneProxy mockPlaneProxy(int bufferSize) { - PlaneProxy plane = Mockito.mock(PlaneProxy.class); + PlaneProxy plane = mock(PlaneProxy.class); ByteBuffer buffer = ByteBuffer.allocate(bufferSize); - Mockito.when(plane.getBuffer()).thenReturn(buffer); + when(plane.getBuffer()).thenReturn(buffer); return plane; } @@ -99,7 +147,13 @@ private PlaneProxy mockPlaneProxy(int bufferSize) { private PlaneProxy mockPlaneProxyWithData(byte[] data) { PlaneProxy plane = Mockito.mock(PlaneProxy.class); ByteBuffer buffer = ByteBuffer.wrap(Arrays.copyOf(data, data.length)); - Mockito.when(plane.getBuffer()).thenReturn(buffer); + when(plane.getBuffer()).thenReturn(buffer); + + // Set pixelStride and rowStride to safe defaults for tests + // For Y plane: pixelStride = 1, rowStride = width (approximate) + when(plane.getPixelStride()).thenReturn(1); + when(plane.getRowStride()).thenReturn(data.length); // rowStride ≥ width + return plane; } } diff --git a/packages/camera/camera_android_camerax/pubspec.yaml b/packages/camera/camera_android_camerax/pubspec.yaml index 311df6f2e43..ea68ac28121 100644 --- a/packages/camera/camera_android_camerax/pubspec.yaml +++ b/packages/camera/camera_android_camerax/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_android_camerax description: Android implementation of the camera plugin using the CameraX library. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_android_camerax issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.6.23 +version: 0.6.24 environment: sdk: ^3.8.1