1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package android.media.projection.cts;
17 
18 import static android.content.pm.PackageManager.FEATURE_SCREEN_LANDSCAPE;
19 import static android.content.pm.PackageManager.FEATURE_SCREEN_PORTRAIT;
20 import static android.server.wm.CtsWindowInfoUtils.assertAndDumpWindowState;
21 import static android.server.wm.CtsWindowInfoUtils.waitForStableWindowGeometry;
22 import static android.server.wm.CtsWindowInfoUtils.waitForWindowInfo;
23 import static android.view.Surface.ROTATION_270;
24 
25 import static com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity;
26 
27 import static com.google.common.truth.Truth.assertThat;
28 
29 import static org.junit.Assume.assumeTrue;
30 
31 import android.annotation.NonNull;
32 import android.annotation.Nullable;
33 import android.app.Activity;
34 import android.app.ActivityManager;
35 import android.app.ActivityOptions;
36 import android.content.Context;
37 import android.content.Intent;
38 import android.graphics.Bitmap;
39 import android.graphics.PixelFormat;
40 import android.graphics.Point;
41 import android.graphics.Rect;
42 import android.hardware.display.VirtualDisplay;
43 import android.media.Image;
44 import android.media.ImageReader;
45 import android.media.projection.MediaProjection;
46 import android.os.Bundle;
47 import android.os.Environment;
48 import android.os.Handler;
49 import android.os.IBinder;
50 import android.os.Looper;
51 import android.os.UserHandle;
52 import android.server.wm.MediaProjectionHelper;
53 import android.server.wm.RotationSession;
54 import android.server.wm.WindowManagerStateHelper;
55 import android.util.DisplayMetrics;
56 import android.util.Log;
57 import android.view.Surface;
58 import android.view.WindowMetrics;
59 import android.window.WindowInfosListenerForTest.WindowInfo;
60 
61 import androidx.test.core.app.ActivityScenario;
62 import androidx.test.platform.app.InstrumentationRegistry;
63 
64 import com.android.compatibility.common.util.NonMainlineTest;
65 
66 import org.junit.After;
67 import org.junit.Before;
68 import org.junit.Test;
69 
70 import java.io.File;
71 import java.io.FileOutputStream;
72 import java.nio.ByteBuffer;
73 import java.util.concurrent.CountDownLatch;
74 import java.util.concurrent.TimeUnit;
75 import java.util.function.Predicate;
76 import java.util.function.Supplier;
77 
78 /**
79  * Test {@link MediaProjection} successfully mirrors the display contents.
80  *
81  * <p>Validate that mirrored views are the expected size, for both full display and single app
82  * capture (if offered). Instead of examining the pixels match exactly (which is historically a
83  * flaky way of validating mirroring), examine the structure of the mirrored hierarchy, to ensure
84  * that mirroring is initiated correctly, and any transformations are applied as expected.
85  *
86  * <p>Run with:
87  * atest CtsMediaProjectionTestCases:MediaProjectionMirroringTest
88  */
89 @NonMainlineTest
90 public class MediaProjectionMirroringTest {
91     private static final String TAG = "MediaProjectionMirroringTest-FOO";
92     private static final int SCREENSHOT_TIMEOUT_MS = 1000;
93     // Enable debug mode to save screenshots from MediaProjection session.
94     private static final boolean DEBUG_MODE = false;
95     private static final String VIRTUAL_DISPLAY = "MirroringTestVD";
96     private Context mContext;
97     // Manage a MediaProjection capture session.
98     private final MediaProjectionHelper mMediaProjectionHelper = new MediaProjectionHelper();
99 
100     private MediaProjection mMediaProjection;
101     private MediaProjection.Callback mMediaProjectionCallback =
102             new MediaProjection.Callback() {
103                 @Override
104                 public void onStop() {
105                     super.onStop();
106                 }
107 
108                 @Override
109                 public void onCapturedContentResize(int width, int height) {
110                     super.onCapturedContentResize(width, height);
111                 }
112 
113                 @Override
114                 public void onCapturedContentVisibilityChanged(boolean isVisible) {
115                     super.onCapturedContentVisibilityChanged(isVisible);
116                 }
117             };
118     private ImageReader mImageReader;
119     private CountDownLatch mScreenshotCountDownLatch;
120     private VirtualDisplay mVirtualDisplay;
121     private final ActivityOptions.LaunchCookie mLaunchCookie = new ActivityOptions.LaunchCookie();
122     public ActivityScenario<TestRotationActivity> mTestRotationActivityActivityScenario;
123     private Activity mActivity;
124     private final WindowManagerStateHelper mWmState = new WindowManagerStateHelper();
125     /**
126      * Whether to wait for the rotation to be stable state after testing. It can be set if the
127      * display rotation may be changed by test.
128      */
129     private boolean mWaitForRotationOnTearDown;
130 
131     @Before
setUp()132     public void setUp() {
133         mContext = InstrumentationRegistry.getInstrumentation().getContext();
134         runWithShellPermissionIdentity(() -> {
135             mContext.getPackageManager().revokeRuntimePermission(
136                     mContext.getPackageName(),
137                     android.Manifest.permission.SYSTEM_ALERT_WINDOW,
138                     new UserHandle(ActivityManager.getCurrentUser()));
139         });
140         mMediaProjection = null;
141         if (DEBUG_MODE) {
142             mScreenshotCountDownLatch = new CountDownLatch(1);
143         }
144     }
145 
146     @After
tearDown()147     public void tearDown() {
148         if (mMediaProjection != null) {
149             if (mMediaProjectionCallback != null) {
150                 mMediaProjection.unregisterCallback(mMediaProjectionCallback);
151                 mMediaProjectionCallback = null;
152             }
153             mMediaProjection.stop();
154             mMediaProjection = null;
155         }
156         if (mImageReader != null) {
157             mImageReader = null;
158         }
159         if (mVirtualDisplay != null) {
160             mVirtualDisplay.getSurface().release();
161             mVirtualDisplay.release();
162             mVirtualDisplay = null;
163         }
164         if (mWaitForRotationOnTearDown) {
165             mWmState.waitForDisplayUnfrozen();
166         }
167     }
168 
169     // Validate that the mirrored hierarchy is the expected size.
170     @Test
testDisplayCapture()171     public void testDisplayCapture() {
172         ActivityScenario<Activity> activityScenario =
173                 ActivityScenario.launch(new Intent(mContext, Activity.class));
174         activityScenario.onActivity(activity -> mActivity = activity);
175 
176         final WindowMetrics maxWindowMetrics =
177                 mActivity.getWindowManager().getMaximumWindowMetrics();
178         final Rect activityRect = new Rect();
179 
180         // Select full screen capture.
181         mMediaProjectionHelper.authorizeMediaProjection();
182 
183         // Start capture of the entire display.
184         mMediaProjection = mMediaProjectionHelper.startMediaProjection();
185         mVirtualDisplay = createVirtualDisplay(maxWindowMetrics.getBounds(), "testDisplayCapture");
186         waitForLatestScreenshot();
187 
188         // Get the bounds of the activity on screen - use getGlobalVisibleRect to account for
189         // possible insets caused by DisplayCutout
190         mActivity.getWindow().getDecorView().getGlobalVisibleRect(activityRect);
191 
192         validateMirroredHierarchy(mActivity,
193                 mVirtualDisplay.getDisplay().getDisplayId(),
194                 new Point(activityRect.width(), activityRect.height()));
195         activityScenario.close();
196     }
197 
198     // Validate that the mirrored hierarchy is the expected size after rotating the default display.
199     @Test
testDisplayCapture_rotation()200     public void testDisplayCapture_rotation() {
201         assumeTrue("Skipping test: no rotation support", supportsRotation());
202 
203         mTestRotationActivityActivityScenario =
204                 ActivityScenario.launch(new Intent(mContext, TestRotationActivity.class));
205         mTestRotationActivityActivityScenario.onActivity(activity -> mActivity = activity);
206 
207         final RotationSession rotationSession = createManagedRotationSession();
208         final WindowMetrics maxWindowMetrics =
209                 mActivity.getWindowManager().getMaximumWindowMetrics();
210         final Rect activityRect = new Rect();
211         final int initialRotation = mActivity.getDisplay().getRotation();
212 
213         // Select full screen capture.
214         mMediaProjectionHelper.authorizeMediaProjection();
215 
216         // Start capture of the entire display.
217         mMediaProjection = mMediaProjectionHelper.startMediaProjection();
218         mVirtualDisplay = createVirtualDisplay(maxWindowMetrics.getBounds(),
219                 "testDisplayCapture_rotation");
220 
221         rotateDeviceAndWaitForActivity(rotationSession, initialRotation);
222 
223         // Get the bounds of the activity on screen - use getGlobalVisibleRect to account for
224         // possible insets caused by DisplayCutout
225         mActivity.getWindow().getDecorView().getGlobalVisibleRect(activityRect);
226 
227         final Point mirroredSize = calculateScaledMirroredActivitySize(
228                 mActivity.getWindowManager().getCurrentWindowMetrics(), mVirtualDisplay,
229                 new Point(activityRect.width(), activityRect.height()));
230         validateMirroredHierarchy(mActivity, mVirtualDisplay.getDisplay().getDisplayId(),
231                 mirroredSize);
232 
233         rotationSession.close();
234         mTestRotationActivityActivityScenario.close();
235     }
236 
237     // Validate that the mirrored hierarchy is the expected size.
238     @Test
testSingleAppCapture()239     public void testSingleAppCapture() {
240         final ActivityScenario<Activity> activityScenario = ActivityScenario.launch(
241                 new Intent(mContext, Activity.class),
242                 createActivityScenarioWithLaunchCookie(mLaunchCookie)
243         );
244         activityScenario.onActivity(activity -> mActivity = activity);
245         final WindowMetrics maxWindowMetrics =
246                 mActivity.getWindowManager().getMaximumWindowMetrics();
247         final Rect activityRect = new Rect();
248 
249         // Select single app capture if supported.
250         mMediaProjectionHelper.authorizeMediaProjection(mLaunchCookie);
251 
252         // Start capture of the single app.
253         mMediaProjection = mMediaProjectionHelper.startMediaProjection();
254         mVirtualDisplay = createVirtualDisplay(maxWindowMetrics.getBounds(),
255                 "testSingleAppCapture");
256         waitForLatestScreenshot();
257 
258         // Get the bounds of the activity on screen - use getGlobalVisibleRect to account for
259         // possible insets caused by DisplayCutout
260         mActivity.getWindow().getDecorView().getGlobalVisibleRect(activityRect);
261 
262         validateMirroredHierarchy(mActivity,
263                 mVirtualDisplay.getDisplay().getDisplayId(),
264                 new Point(activityRect.width(), activityRect.height()));
265         activityScenario.close();
266     }
267 
268     // TODO (b/284968776): test single app capture in split screen
269 
270     /**
271      * Returns ActivityOptions with the given launch cookie set.
272      */
createActivityScenarioWithLaunchCookie( @onNull ActivityOptions.LaunchCookie launchCookie)273     private static Bundle createActivityScenarioWithLaunchCookie(
274             @NonNull ActivityOptions.LaunchCookie launchCookie) {
275         ActivityOptions activityOptions = ActivityOptions.makeBasic();
276         activityOptions.setLaunchCookie(launchCookie);
277         return activityOptions.toBundle();
278     }
279 
createVirtualDisplay(Rect displayBounds, String methodName)280     private VirtualDisplay createVirtualDisplay(Rect displayBounds, String methodName) {
281         mImageReader = ImageReader.newInstance(displayBounds.width(), displayBounds.height(),
282                 PixelFormat.RGBA_8888, /* maxImages= */ 1);
283         if (DEBUG_MODE) {
284             ScreenshotListener screenshotListener = new ScreenshotListener(methodName,
285                     mScreenshotCountDownLatch);
286             mImageReader.setOnImageAvailableListener(screenshotListener,
287                     new Handler(Looper.getMainLooper()));
288         }
289         mMediaProjection.registerCallback(mMediaProjectionCallback,
290                 new Handler(Looper.getMainLooper()));
291         return mMediaProjection.createVirtualDisplay(VIRTUAL_DISPLAY + "_" + methodName,
292                 displayBounds.width(), displayBounds.height(),
293                 DisplayMetrics.DENSITY_HIGH, /* flags= */ 0,
294                 mImageReader.getSurface(), /* callback= */
295                 null, new Handler(Looper.getMainLooper()));
296     }
297 
298     /**
299      * Rotates the device 90 degrees & waits for the display & activity configuration to stabilize.
300      */
rotateDeviceAndWaitForActivity( @onNull RotationSession rotationSession, @Surface.Rotation int initialRotation)301     private void rotateDeviceAndWaitForActivity(
302             @NonNull RotationSession rotationSession, @Surface.Rotation int initialRotation) {
303         // Rotate the device by 90 degrees
304         rotationSession.set((initialRotation + 1) % (ROTATION_270 + 1),
305                 /* waitForDeviceRotation=*/ true);
306         waitForLatestScreenshot();
307         try {
308             waitForStableWindowGeometry(SCREENSHOT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
309         } catch (InterruptedException e) {
310             Log.e(TAG, "Unable to wait for window to stabilize after rotation: " + e.getMessage());
311         }
312         // Re-fetch the activity since reference may have been modified during rotation.
313         mTestRotationActivityActivityScenario.onActivity(activity -> mActivity = activity);
314     }
315 
316     /**
317      * Calculate the size of the activity, scaled to fit on the VirtualDisplay.
318      *
319      * @param currentWindowMetrics The size of the source activity, before it is mirrored
320      * @param virtualDisplay       The VirtualDisplay the mirrored content is sent to and scaled to
321      *                             fit
322      * @return The expected size of the mirrored activity on the VirtualDisplay
323      */
calculateScaledMirroredActivitySize( @onNull WindowMetrics currentWindowMetrics, @NonNull VirtualDisplay virtualDisplay, @Nullable Point visibleBounds)324     private static Point calculateScaledMirroredActivitySize(
325             @NonNull WindowMetrics currentWindowMetrics,
326             @NonNull VirtualDisplay virtualDisplay, @Nullable Point visibleBounds) {
327         // Calculate the aspect ratio of the original activity.
328         final Point currentBounds = new Point(currentWindowMetrics.getBounds().width(),
329                 currentWindowMetrics.getBounds().height());
330         final float aspectRatio = currentBounds.x * 1f / currentBounds.y;
331         // Find the size of the surface we are mirroring to.
332         final Point surfaceSize = virtualDisplay.getSurface().getDefaultSize();
333         int mirroredWidth;
334         int mirroredHeight;
335 
336         // Calculate any width & height deltas caused by DisplayCutout insets
337         Point sizeDifference = new Point();
338         if (visibleBounds != null) {
339             int widthDifference = currentBounds.x - visibleBounds.x;
340             int heightDifference = currentBounds.y - visibleBounds.y;
341             sizeDifference.set(widthDifference, heightDifference);
342         }
343 
344         if (surfaceSize.x < surfaceSize.y) {
345             // Output surface is portrait, so its width constrains. The mirrored activity is
346             // scaled down to fill the width entirely, and will have horizontal black bars at the
347             // top and bottom.
348             // Also apply scaled insets, to handle case where device has a display cutout which
349             // shifts the content horizontally when landscape.
350             int adjustedHorizontalInsets = Math.round(sizeDifference.x / aspectRatio);
351             int adjustedVerticalInsets = Math.round(sizeDifference.y / aspectRatio);
352             mirroredWidth = surfaceSize.x - adjustedHorizontalInsets;
353             mirroredHeight = Math.round(surfaceSize.x / aspectRatio) - adjustedVerticalInsets;
354         } else {
355             // Output surface is landscape, so its height constrains. The mirrored activity is
356             // scaled down to fill the height entirely, and will have horizontal black bars on the
357             // left and right.
358             // Also apply scaled insets, to handle case where device has a display cutout which
359             // shifts the content vertically when portrait.
360             int adjustedHorizontalInsets = Math.round(sizeDifference.x * aspectRatio);
361             int adjustedVerticalInsets = Math.round(sizeDifference.y * aspectRatio);
362             mirroredWidth = Math.round(surfaceSize.y * aspectRatio) - adjustedHorizontalInsets;
363             mirroredHeight = surfaceSize.y - adjustedVerticalInsets;
364         }
365         return new Point(mirroredWidth, mirroredHeight);
366     }
367 
368     /**
369      * Validate the given activity is in the hierarchy mirrored to the VirtualDisplay.
370      *
371      * <p>Note that the hierarchy is present on the VirtualDisplay because the hierarchy is mirrored
372      * to the Surface provided to #createVirtualDisplay.
373      *
374      * @param activity           The activity that we expect to be mirrored
375      * @param virtualDisplayId   The id of the virtual display we are mirroring to
376      * @param expectedWindowSize The expected size of the mirrored activity
377      */
validateMirroredHierarchy( Activity activity, int virtualDisplayId, @NonNull Point expectedWindowSize)378     private static void validateMirroredHierarchy(
379             Activity activity, int virtualDisplayId,
380             @NonNull Point expectedWindowSize) {
381         Predicate<WindowInfo> hasExpectedDimensions =
382                 windowInfo -> windowInfo.bounds.width() == expectedWindowSize.x
383                         && windowInfo.bounds.height() == expectedWindowSize.y;
384         Supplier<IBinder> taskWindowTokenSupplier =
385                 activity.getWindow().getDecorView()::getWindowToken;
386         try {
387             boolean condition = waitForWindowInfo(hasExpectedDimensions, 5, TimeUnit.SECONDS,
388                     taskWindowTokenSupplier, virtualDisplayId);
389             assertAndDumpWindowState(TAG,
390                     "Mirrored activity isn't the expected size of " + expectedWindowSize,
391                     condition);
392         } catch (InterruptedException e) {
393             throw new RuntimeException(e);
394         }
395     }
396 
createManagedRotationSession()397     private RotationSession createManagedRotationSession() {
398         mWaitForRotationOnTearDown = true;
399         return new RotationSession(mWmState);
400     }
401 
402     /**
403      * Rotation support is indicated by explicitly having both landscape and portrait
404      * features or not listing either at all.
405      */
supportsRotation()406     protected boolean supportsRotation() {
407         final boolean supportsLandscape = hasDeviceFeature(FEATURE_SCREEN_LANDSCAPE);
408         final boolean supportsPortrait = hasDeviceFeature(FEATURE_SCREEN_PORTRAIT);
409         return (supportsLandscape && supportsPortrait)
410                 || (!supportsLandscape && !supportsPortrait);
411     }
412 
hasDeviceFeature(final String requiredFeature)413     protected boolean hasDeviceFeature(final String requiredFeature) {
414         return mContext.getPackageManager()
415                 .hasSystemFeature(requiredFeature);
416     }
417 
418     /**
419      * Stub activity for launching an activity meant to be rotated.
420      */
421     public static class TestRotationActivity extends Activity {
422         // Stub
423     }
424 
425     /**
426      * Wait for any screenshot that has been received already. Assumes that the countdown
427      * latch is already set.
428      */
waitForLatestScreenshot()429     private void waitForLatestScreenshot() {
430         if (DEBUG_MODE) {
431             // wait until we've received a screenshot
432             try {
433                 assertThat(mScreenshotCountDownLatch.await(SCREENSHOT_TIMEOUT_MS,
434                         TimeUnit.MILLISECONDS)).isTrue();
435             } catch (InterruptedException e) {
436                 Log.e(TAG, e.toString());
437             }
438         }
439     }
440 
441     /**
442      * Save MediaProjection's screenshots to the device to help debug test failures.
443      */
444     public static class ScreenshotListener implements ImageReader.OnImageAvailableListener {
445         private final CountDownLatch mCountDownLatch;
446         private final String mMethodName;
447         private int mCurrentScreenshot = 0;
448         // How often to save an image
449         private static final int SCREENSHOT_FREQUENCY = 5;
450 
ScreenshotListener(@onNull String methodName, @NonNull CountDownLatch latch)451         public ScreenshotListener(@NonNull String methodName,
452                 @NonNull CountDownLatch latch) {
453             mMethodName = methodName;
454             mCountDownLatch = latch;
455         }
456 
457         @Override
onImageAvailable(ImageReader reader)458         public void onImageAvailable(ImageReader reader) {
459             if (mCurrentScreenshot % SCREENSHOT_FREQUENCY != 0) {
460                 Log.d(TAG, "onImageAvailable - skip this one");
461                 return;
462             }
463             Log.d(TAG, "onImageAvailable - processing");
464             if (mCountDownLatch != null) {
465                 mCountDownLatch.countDown();
466             }
467             mCurrentScreenshot++;
468 
469             final Image image = reader.acquireLatestImage();
470 
471             assertThat(image).isNotNull();
472 
473             final Image.Plane plane = image.getPlanes()[0];
474 
475             assertThat(plane).isNotNull();
476 
477             final int rowPadding =
478                     plane.getRowStride() - plane.getPixelStride() * image.getWidth();
479             final Bitmap bitmap = Bitmap.createBitmap(
480                     /* width= */ image.getWidth() + rowPadding / plane.getPixelStride(),
481                     /* height= */ image.getHeight(), Bitmap.Config.ARGB_8888);
482             final ByteBuffer buffer = plane.getBuffer();
483 
484             assertThat(buffer).isNotNull();
485             assertThat(bitmap).isNotNull(); // why null?
486 
487             bitmap.copyPixelsFromBuffer(plane.getBuffer());
488             assertThat(bitmap).isNotNull(); // why null?
489 
490             try {
491                 // save to virtual sdcard
492                 final File outputDirectory = new File(Environment.getExternalStorageDirectory(),
493                         "cts." + TAG);
494                 Log.d(TAG, "Had to create the directory? " + outputDirectory.mkdir());
495                 final File screenshot = new File(outputDirectory,
496                         mMethodName + "_screenshot_" + mCurrentScreenshot + "_"
497                                 + System.currentTimeMillis() + ".jpg");
498                 final FileOutputStream stream = new FileOutputStream(screenshot);
499                 assertThat(stream).isNotNull();
500                 bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream);
501                 stream.close();
502                 image.close();
503             } catch (Exception e) {
504                 Log.e(TAG, "Unable to write out screenshot", e);
505             }
506         }
507     }
508 }
509