From a960b6afb7b1ff31be6b7b0659ec62c30a9e6862 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Fri, 3 Oct 2025 11:44:13 -0300 Subject: [PATCH 1/4] Improve NV21 conversion in ImageProxyUtils and enhance unit tests for edge cases --- .../plugins/camerax/ImageProxyUtils.java | 85 +++++++++++-------- .../plugins/camerax/ImageProxyUtilsTest.java | 64 ++++++++++++-- 2 files changed, 107 insertions(+), 42 deletions(-) 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..a2b9404dce2 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[] nv21Buffer = 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(nv21Buffer, 0, ySize); + position = ySize; + } else { + // Copy row by row if padding exists + for (int row = 0; row < height; row++) { + yBuffer.get(nv21Buffer, 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); + nv21Buffer[position++] = vRowBuffer[vPixelIndex]; // V (Cr) + nv21Buffer[position++] = uRowBuffer[uPixelIndex]; // U (Cb) + } + } - return areNV21; + return ByteBuffer.wrap(nv21Buffer); } } 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..4952888eda9 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 = 4; + int height = 2; + + byte[] yData = new byte[width * height]; // dummy Y data + 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); + + // UV planes + ByteBuffer vBuffer = ByteBuffer.allocate(4); + vBuffer.position(vBuffer.limit()); // position == limit + ByteBuffer uBuffer = ByteBuffer.allocate(4); + + PlaneProxy uPlane = mock(PlaneProxy.class); + PlaneProxy vPlane = mock(PlaneProxy.class); + when(uPlane.getBuffer()).thenReturn(uBuffer); + when(vPlane.getBuffer()).thenReturn(vBuffer); + + // Provide safe pixelStride/rowStride + when(uPlane.getPixelStride()).thenReturn(1); + when(uPlane.getRowStride()).thenReturn(2); + when(vPlane.getPixelStride()).thenReturn(1); + when(vPlane.getRowStride()).thenReturn(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; } } From 8041ec73c823685996ea1eb126ebfa42c1fcde52 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Fri, 3 Oct 2025 12:00:18 -0300 Subject: [PATCH 2/4] Bump version to 0.6.24 --- packages/camera/camera_android_camerax/CHANGELOG.md | 4 ++++ .../java/io/flutter/plugins/camerax/ImageProxyUtils.java | 8 ++++---- packages/camera/camera_android_camerax/pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) 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 a2b9404dce2..b3e4e5d384c 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 @@ -31,7 +31,7 @@ public static ByteBuffer planesToNV21(@NonNull List planes, int widt ByteBuffer uBuffer = uPlane.getBuffer(); ByteBuffer vBuffer = vPlane.getBuffer(); - // Rewind buffers to start to ensure full read + // Rewind buffers to start to ensure full read. yBuffer.rewind(); uBuffer.rewind(); vBuffer.rewind(); @@ -42,11 +42,11 @@ public static ByteBuffer planesToNV21(@NonNull List planes, int widt int yRowStride = yPlane.getRowStride(); if (yRowStride == width) { - // If no padding, copy entire Y plane at once + // If no padding, copy entire Y plane at once. yBuffer.get(nv21Buffer, 0, ySize); position = ySize; } else { - // Copy row by row if padding exists + // Copy row by row if padding exists. for (int row = 0; row < height; row++) { yBuffer.get(nv21Buffer, position, width); position += width; @@ -65,7 +65,7 @@ public static ByteBuffer planesToNV21(@NonNull List planes, int widt byte[] vRowBuffer = new byte[vRowStride]; for (int row = 0; row < height / 2; row++) { - // Read full row from U and V planes into temporary buffers + // 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)); 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 From a5acd21b963a5e01c123f64ef33c743823407d5a Mon Sep 17 00:00:00 2001 From: Mairramer Date: Fri, 3 Oct 2025 13:14:05 -0300 Subject: [PATCH 3/4] rename nv21Buffer to nv21Bytes for clarity --- .../io/flutter/plugins/camerax/ImageProxyUtils.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 b3e4e5d384c..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 @@ -37,18 +37,18 @@ public static ByteBuffer planesToNV21(@NonNull List planes, int widt vBuffer.rewind(); int ySize = yBuffer.remaining(); - byte[] nv21Buffer = new byte[ySize + (width * height / 2)]; + 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(nv21Buffer, 0, ySize); + yBuffer.get(nv21Bytes, 0, ySize); position = ySize; } else { // Copy row by row if padding exists. for (int row = 0; row < height; row++) { - yBuffer.get(nv21Buffer, position, width); + yBuffer.get(nv21Bytes, position, width); position += width; if (row < height - 1) { yBuffer.position(yBuffer.position() - width + yRowStride); @@ -73,11 +73,11 @@ public static ByteBuffer planesToNV21(@NonNull List planes, int widt int vPixelIndex = col * vPixelStride; int uPixelIndex = col * uPixelStride; - nv21Buffer[position++] = vRowBuffer[vPixelIndex]; // V (Cr) - nv21Buffer[position++] = uRowBuffer[uPixelIndex]; // U (Cb) + nv21Bytes[position++] = vRowBuffer[vPixelIndex]; // V (Cr) + nv21Bytes[position++] = uRowBuffer[uPixelIndex]; // U (Cb) } } - return ByteBuffer.wrap(nv21Buffer); + return ByteBuffer.wrap(nv21Bytes); } } From 3452f676c8f1dc88f9fa22509910c9ff6c9d1484 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Mon, 6 Oct 2025 09:54:03 -0300 Subject: [PATCH 4/4] Update ImageProxyUtilsTest to handle larger NV21 dimensions and improve UV plane mocking --- .../plugins/camerax/ImageProxyUtilsTest.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) 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 4952888eda9..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 @@ -100,31 +100,31 @@ public void planesToNV21_returnsExpectedBufferWhenPlanesAreNV21Compatible() { @Test public void areUVPlanesNV21_handlesVBufferAtLimitGracefully() { - int width = 4; - int height = 2; + int width = 1280; + int height = 720; - byte[] yData = new byte[width * height]; // dummy Y data + // --- 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); - // UV planes - ByteBuffer vBuffer = ByteBuffer.allocate(4); - vBuffer.position(vBuffer.limit()); // position == limit - ByteBuffer uBuffer = ByteBuffer.allocate(4); - + // --- Mock U plane --- + ByteBuffer uBuffer = ByteBuffer.allocate(width * height / 4); PlaneProxy uPlane = mock(PlaneProxy.class); - PlaneProxy vPlane = mock(PlaneProxy.class); when(uPlane.getBuffer()).thenReturn(uBuffer); - when(vPlane.getBuffer()).thenReturn(vBuffer); - - // Provide safe pixelStride/rowStride when(uPlane.getPixelStride()).thenReturn(1); - when(uPlane.getRowStride()).thenReturn(2); + 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(2); + when(vPlane.getRowStride()).thenReturn(width / 2); List planes = Arrays.asList(yPlane, uPlane, vPlane);