Skip to content
This repository was archived by the owner on Nov 16, 2023. It is now read-only.

Commit 5ec4af8

Browse files
authored
fix: avoid extra padding in screenshots on devices that use row-padded images (#20)
#### Description of changes This fixes the issue described by #19 by detecting the case where the system has provided an image data with row padding and creating an intermediate, unpadded image buffer before invoking `Bitmap::copyPixelsFromBuffer`. I explored a few other implementation options in the hopes of avoiding having to allocate the intermediate buffer, but they all came up short because `copyPixelsFromBuffer` is the only method `Bitmap` exposes that copies the backing data *exactly as-is* from the original source data, without applying any color transformations on the data. In particular, the variants of `Bitmap::createBitmap` and `Bitmap::setPixels` that accept `int[] colors` parameters look promising because some of them have built-in support for explicit `stride` parameters, but all of them perform color transformations (with or without `Bitmap::setPremultiplied`) that result in screenshots with incorrect colors. Besides unit tests, will verify before merging against: * [x] A device that exhibited the bad padding behavior before the fix (my Moto G7 Power) * [x] An emulator that did not previously exhibit the bad padding behavior **Before (note the extra padding at the right side of the screenshot image and the misaligned failure highlight):** ![image](https://user-images.githubusercontent.com/376284/83585855-c1027180-a4ff-11ea-8c01-375903509453.png) **After (note padding is gone and highlight lines up):** ![image](https://user-images.githubusercontent.com/376284/83585805-a6c89380-a4ff-11ea-9de9-d96a44aff33c.png) #### Pull request checklist <!-- If a checklist item is not applicable to this change, write "n/a" in the checkbox --> - [x] Addresses an existing issue: #19 - [x] Added/updated relevant unit test(s) - [x] Ran `./gradlew fastpass` from `AccessibilityInsightsForAndroidService` - [x] PR title _AND_ final merge commit title both start with a semantic tag (`fix:`, `chore:`, `feat(feature-name):`, `refactor:`).
1 parent 1737b29 commit 5ec4af8

File tree

3 files changed

+165
-33
lines changed

3 files changed

+165
-33
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package com.microsoft.accessibilityinsightsforandroidservice;
5+
6+
class ImageFormatException extends Exception {
7+
public ImageFormatException(String message) {
8+
super(message);
9+
}
10+
}

AccessibilityInsightsForAndroidService/app/src/main/java/com/microsoft/accessibilityinsightsforandroidservice/OnScreenshotAvailable.java

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
import java.util.function.Consumer;
1212

1313
public class OnScreenshotAvailable implements ImageReader.OnImageAvailableListener {
14+
private final Bitmap.Config IMAGE_BITMAP_FORMAT = Bitmap.Config.ARGB_8888;
15+
private final int IMAGE_PIXEL_STRIDE = 4; // Implied by ARGB_8888 (4 bytes per pixel)
16+
1417
private static final String TAG = "OnScreenshotAvailable";
1518
private boolean imageAlreadyProcessed = false;
1619
private Consumer<Bitmap> bitmapConsumer;
@@ -31,26 +34,93 @@ public synchronized void onImageAvailable(ImageReader imageReader) {
3134
}
3235

3336
Image image = imageReader.acquireLatestImage();
34-
Bitmap screenshotBitmap = getBitmapFromImage(image);
35-
image.close();
36-
imageAlreadyProcessed = true;
37-
bitmapConsumer.accept(screenshotBitmap);
37+
Bitmap screenshotBitmap = null;
38+
try {
39+
screenshotBitmap = getBitmapFromImage(image);
40+
} catch (ImageFormatException e) {
41+
Logger.logError(TAG, "ImageFormatException: " + e.toString());
42+
} finally {
43+
image.close();
44+
}
45+
46+
// If we failed to convert the image, we just log an error and don't forward anything on to the
47+
// consumer that's forming the API response. From the API consumer's perspective, it will
48+
// propagate as results with no screenshot data available.
49+
if (screenshotBitmap != null) {
50+
imageAlreadyProcessed = true;
51+
bitmapConsumer.accept(screenshotBitmap);
52+
}
3853
}
3954

40-
private Bitmap getBitmapFromImage(Image image) {
41-
Image.Plane[] imagePlanes = image.getPlanes();
42-
int bitmapWidth = getBitmapWidth(image, imagePlanes);
43-
Bitmap screenshotBitmap =
44-
bitmapProvider.createBitmap(bitmapWidth, metrics.heightPixels, Bitmap.Config.ARGB_8888);
45-
ByteBuffer buffer = imagePlanes[0].getBuffer();
46-
screenshotBitmap.copyPixelsFromBuffer(buffer);
47-
return screenshotBitmap;
55+
private Bitmap getBitmapFromImage(Image image) throws ImageFormatException {
56+
int width = image.getWidth();
57+
int height = image.getHeight();
58+
if (width != metrics.widthPixels || height != metrics.heightPixels) {
59+
Logger.logError(
60+
TAG,
61+
"Received image of dimensions "
62+
+ width
63+
+ "x"
64+
+ height
65+
+ ", mismatches device DisplayMetrics "
66+
+ metrics.widthPixels
67+
+ "x"
68+
+ metrics.heightPixels);
69+
}
70+
71+
Bitmap bitmap = bitmapProvider.createBitmap(width, height, IMAGE_BITMAP_FORMAT);
72+
copyPixelsFromImagePlane(bitmap, image.getPlanes()[0], width, height);
73+
74+
return bitmap;
4875
}
4976

50-
private int getBitmapWidth(Image image, Image.Plane[] imagePlanes) {
51-
int pixelStride = imagePlanes[0].getPixelStride();
52-
int rowStride = imagePlanes[0].getRowStride();
53-
int rowPadding = rowStride - pixelStride * metrics.widthPixels;
54-
return image.getWidth() + rowPadding / pixelStride;
77+
// The source Image.Plane and the destination Bitmap use the same byte encoding for image data,
78+
// 4 bytes per pixel in normal reading order, *except* that the Image.Plane can optionally contain
79+
// padding bytes at the end of each row's worth of pixel data, which the Bitmap doesn't support.
80+
//
81+
// The "row stride" refers to the number of bytes per row, *including* any optional padding.
82+
//
83+
// If the source doesn't use any padding, we copy its backing ByteBuffer directly into the
84+
// destination. If it *does* use padding, we create an intermediate ByteBuffer of our own and
85+
// selectively copy just the real/unpadded pixel data into it first.
86+
private void copyPixelsFromImagePlane(
87+
Bitmap destination, Image.Plane source, int width, int height) throws ImageFormatException {
88+
int sourcePixelStride = source.getPixelStride(); // bytes per pixel
89+
int sourceRowStride = source.getRowStride(); // bytes per row, including any source row-padding
90+
int unpaddedRowStride = width * sourcePixelStride; // bytes per row in destination
91+
92+
if (sourcePixelStride != IMAGE_PIXEL_STRIDE) {
93+
throw new ImageFormatException(
94+
"Invalid source Image: sourcePixelStride="
95+
+ sourcePixelStride
96+
+ ", expected "
97+
+ IMAGE_PIXEL_STRIDE);
98+
}
99+
if (sourceRowStride < unpaddedRowStride) {
100+
throw new ImageFormatException(
101+
"Invalid source Image: sourceRowStride "
102+
+ sourceRowStride
103+
+ " is too small for width "
104+
+ width
105+
+ " at sourcePixelStride "
106+
+ sourcePixelStride);
107+
}
108+
109+
ByteBuffer sourceBuffer = source.getBuffer();
110+
ByteBuffer bitmapPixelDataWithoutRowPadding;
111+
112+
if (sourceRowStride == unpaddedRowStride) {
113+
bitmapPixelDataWithoutRowPadding = sourceBuffer;
114+
} else {
115+
bitmapPixelDataWithoutRowPadding = ByteBuffer.allocate(unpaddedRowStride * height);
116+
for (int row = 0; row < height; ++row) {
117+
int sourceOffset = row * sourceRowStride;
118+
int destOffset = row * unpaddedRowStride;
119+
sourceBuffer.position(sourceOffset);
120+
sourceBuffer.get(bitmapPixelDataWithoutRowPadding.array(), destOffset, unpaddedRowStride);
121+
}
122+
}
123+
124+
destination.copyPixelsFromBuffer(bitmapPixelDataWithoutRowPadding);
55125
}
56126
}

AccessibilityInsightsForAndroidService/app/src/test/java/com/microsoft/accessibilityinsightsforandroidservice/OnScreenshotAvailableTest.java

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package com.microsoft.accessibilityinsightsforandroidservice;
55

66
import static org.mockito.ArgumentMatchers.any;
7+
import static org.mockito.ArgumentMatchers.eq;
78
import static org.mockito.Mockito.reset;
89
import static org.mockito.Mockito.times;
910
import static org.mockito.Mockito.verify;
@@ -20,37 +21,40 @@
2021
import org.junit.Test;
2122
import org.junit.runner.RunWith;
2223
import org.mockito.Mock;
23-
import org.mockito.junit.MockitoJUnitRunner;
24+
import org.powermock.api.mockito.PowerMockito;
25+
import org.powermock.core.classloader.annotations.PrepareForTest;
26+
import org.powermock.modules.junit4.PowerMockRunner;
2427

25-
@RunWith(MockitoJUnitRunner.class)
28+
@RunWith(PowerMockRunner.class)
29+
@PrepareForTest({Logger.class})
2630
public class OnScreenshotAvailableTest {
2731

2832
@Mock ImageReader imageReaderMock;
2933
@Mock Consumer<Bitmap> bitmapConsumerMock;
3034
@Mock Image imageMock;
3135
@Mock Image.Plane imagePlaneMock;
32-
@Mock ByteBuffer bufferMock;
3336
@Mock BitmapProvider bitmapProviderMock;
3437
@Mock Bitmap bitmapMock;
3538

3639
Image.Plane[] imagePlanesStub;
3740
OnScreenshotAvailable testSubject;
38-
int widthStub = 100;
39-
int heightStub = 200;
41+
int widthStub;
42+
int heightStub;
43+
ByteBuffer imagePlaneStubBuffer;
4044
int pixelStrideStub;
4145
int rowStrideStub;
42-
int rowPadding;
43-
int expectedBitmapWidth;
4446

4547
@Before
4648
public void prepare() {
49+
PowerMockito.mockStatic(Logger.class);
50+
4751
DisplayMetrics metricsStub = new DisplayMetrics();
4852
metricsStub.widthPixels = widthStub;
4953
metricsStub.heightPixels = heightStub;
50-
pixelStrideStub = 20;
51-
rowStrideStub = 10;
52-
rowPadding = rowStrideStub - pixelStrideStub * widthStub;
53-
expectedBitmapWidth = widthStub + rowPadding / pixelStrideStub;
54+
widthStub = 100;
55+
heightStub = 200;
56+
pixelStrideStub = 4;
57+
rowStrideStub = widthStub * pixelStrideStub;
5458
imagePlanesStub = new Image.Plane[1];
5559
imagePlanesStub[0] = imagePlaneMock;
5660

@@ -63,12 +67,60 @@ public void onScreenshotAvailableIsNotNull() {
6367
}
6468

6569
@Test
66-
public void onImageAvailableCreatesCorrectScreenshotBitmap() {
70+
public void onImageAvailableIgnoresImagesWithInvalidPixelStrides() {
71+
// pixelStride should be the number of bytes per pixel; our input should always be in ARGB_8888
72+
// format, so it should be fixed at 4. But if it's not, we shouldn't crash.
73+
pixelStrideStub = 5;
74+
75+
setupMocksToCreateBitmap();
76+
77+
testSubject.onImageAvailable(imageReaderMock);
78+
79+
verify(bitmapMock, times(0)).copyPixelsFromBuffer(imagePlaneStubBuffer);
80+
verify(bitmapConsumerMock, times(0)).accept(bitmapMock);
81+
}
82+
83+
@Test
84+
public void onImageAvailableIgnoresImagesWithInvalidRowStrides() {
85+
// rowStride should be the number of bytes per row plus optionally some padding; it should
86+
// never be lower than widthStub * pixelStrideStub, but if it is, we shouldn't crash.
87+
rowStrideStub = widthStub * pixelStrideStub - 1;
88+
89+
setupMocksToCreateBitmap();
90+
91+
testSubject.onImageAvailable(imageReaderMock);
92+
93+
verify(bitmapMock, times(0)).copyPixelsFromBuffer(imagePlaneStubBuffer);
94+
verify(bitmapConsumerMock, times(0)).accept(bitmapMock);
95+
}
96+
97+
@Test
98+
public void onImageAvailableWithUnpaddedImageBufferCreatesBitmapDirectlyFromSourceBuffer() {
99+
setupMocksToCreateBitmap();
100+
101+
testSubject.onImageAvailable(imageReaderMock);
102+
103+
verify(bitmapMock, times(1)).copyPixelsFromBuffer(imagePlaneStubBuffer);
104+
verify(bitmapConsumerMock, times(1)).accept(bitmapMock);
105+
}
106+
107+
@Test
108+
public void onImageAvailableWithPaddedImageBufferStripsPaddingBeforeCopyingPixels() {
109+
widthStub = 2;
110+
heightStub = 2;
111+
int rowPaddingBytes = 1;
112+
rowStrideStub = (pixelStrideStub * widthStub + rowPaddingBytes);
113+
114+
imagePlaneStubBuffer =
115+
ByteBuffer.wrap(new byte[] {1, 1, 1, 1, 2, 2, 2, 2, 0, 3, 3, 3, 3, 4, 4, 4, 4, 0});
116+
ByteBuffer bufferWithPaddingRemoved =
117+
ByteBuffer.wrap(new byte[] {1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4});
118+
67119
setupMocksToCreateBitmap();
68120

69121
testSubject.onImageAvailable(imageReaderMock);
70122

71-
verify(bitmapMock, times(1)).copyPixelsFromBuffer(bufferMock);
123+
verify(bitmapMock, times(1)).copyPixelsFromBuffer(eq(bufferWithPaddingRemoved));
72124
verify(bitmapConsumerMock, times(1)).accept(bitmapMock);
73125
}
74126

@@ -81,7 +133,6 @@ public void onImageAvailableProcessesImageOnlyOnce() {
81133
bitmapConsumerMock,
82134
imageMock,
83135
imagePlaneMock,
84-
bufferMock,
85136
bitmapProviderMock,
86137
bitmapMock);
87138

@@ -97,8 +148,9 @@ private void setupMocksToCreateBitmap() {
97148
when(imagePlaneMock.getPixelStride()).thenReturn(pixelStrideStub);
98149
when(imagePlaneMock.getRowStride()).thenReturn(rowStrideStub);
99150
when(imageMock.getWidth()).thenReturn(widthStub);
100-
when(imagePlaneMock.getBuffer()).thenReturn(bufferMock);
101-
when(bitmapProviderMock.createBitmap(expectedBitmapWidth, heightStub, Bitmap.Config.ARGB_8888))
151+
when(imageMock.getHeight()).thenReturn(heightStub);
152+
when(imagePlaneMock.getBuffer()).thenReturn(imagePlaneStubBuffer);
153+
when(bitmapProviderMock.createBitmap(widthStub, heightStub, Bitmap.Config.ARGB_8888))
102154
.thenReturn(bitmapMock);
103155
}
104156
}

0 commit comments

Comments
 (0)