1 /* 2 * Copyright 2025 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.cts; 17 18 import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; 19 import static android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR; 20 21 import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; 22 23 import static com.google.common.truth.Truth.assertThat; 24 import static com.google.common.truth.Truth.assertWithMessage; 25 26 import android.annotation.NonNull; 27 import android.annotation.Nullable; 28 import android.app.ActivityOptions; 29 import android.content.Context; 30 import android.content.Intent; 31 import android.graphics.Bitmap; 32 import android.graphics.PixelFormat; 33 import android.hardware.display.DisplayManager; 34 import android.hardware.display.VirtualDisplay; 35 import android.media.Image; 36 import android.media.ImageReader; 37 import android.media.projection.MediaProjection; 38 import android.media.projection.MediaProjectionConfig; 39 import android.media.projection.MediaProjectionManager; 40 import android.os.Environment; 41 import android.os.Handler; 42 import android.os.Looper; 43 import android.platform.test.flag.junit.DeviceFlagsValueProvider; 44 import android.util.ArraySet; 45 import android.util.Log; 46 import android.view.Display; 47 import android.view.Surface; 48 49 import androidx.test.core.app.ActivityScenario; 50 51 import org.junit.rules.ExternalResource; 52 import org.junit.rules.RuleChain; 53 import org.junit.rules.TestRule; 54 import org.junit.runner.Description; 55 import org.junit.runners.model.Statement; 56 57 import java.io.File; 58 import java.io.FileOutputStream; 59 import java.nio.ByteBuffer; 60 import java.util.Set; 61 import java.util.concurrent.CountDownLatch; 62 import java.util.concurrent.TimeUnit; 63 64 /** A test rule for testing MediaProjection and related components */ 65 public class MediaProjectionRule implements TestRule { 66 private static final String TAG = "MediaProjectionRule"; 67 // Enable debug mode to save screenshots from MediaProjection session. 68 private static final boolean DEBUG_MODE = false; 69 private static final int RECORDING_WIDTH = 500; 70 private static final int RECORDING_HEIGHT = 700; 71 private static final int RECORDING_DENSITY = 200; 72 private static final int TIMEOUT_MS = 3000; 73 private static final int SCREENSHOT_TIMEOUT_MS = 1000; 74 75 private final MediaProjection.Callback mEmptyMediaProjectionCallback = 76 new MediaProjection.Callback() {}; 77 private final MediaProjectionTrackerRule mMediaProjectionTrackerRule = 78 new MediaProjectionTrackerRule(); 79 private final Context mContext = getInstrumentation().getTargetContext(); 80 private final MediaProjectionManager mMediaProjectionManager = 81 mContext.getSystemService(MediaProjectionManager.class); 82 private final Handler mCallbackHandler = new Handler(Looper.getMainLooper()); 83 84 private android.media.cts.MediaProjectionActivity mActivity; 85 private String mTestName; 86 87 @Override apply(Statement base, Description description)88 public Statement apply(Statement base, Description description) { 89 assertThat(mMediaProjectionManager).isNotNull(); 90 mTestName = String.format("%s#%s", description.getClassName(), description.getMethodName()); 91 return RuleChain.outerRule(DeviceFlagsValueProvider.createCheckFlagsRule()) 92 .around(mMediaProjectionTrackerRule) 93 .apply(base, description); 94 } 95 96 /** Get an instance of MediaProjectionManager. */ getMediaProjectionManager()97 public MediaProjectionManager getMediaProjectionManager() { 98 return mMediaProjectionManager; 99 } 100 101 /** 102 * Starts the MediaProjection consent flow but doesn't actually start a MediaProjection session. 103 */ showMediaProjectionConsent(@ullable MediaProjectionConfig config)104 public void showMediaProjectionConsent(@Nullable MediaProjectionConfig config) 105 throws Exception { 106 showMediaProjectionConsent(config, null, null); 107 } 108 showMediaProjectionConsent( @ullable MediaProjectionConfig config, @Nullable ActivityOptions.LaunchCookie launchCookie, @Nullable String foregroundServiceClass)109 private void showMediaProjectionConsent( 110 @Nullable MediaProjectionConfig config, 111 @Nullable ActivityOptions.LaunchCookie launchCookie, 112 @Nullable String foregroundServiceClass) 113 throws Exception { 114 CountDownLatch latch = new CountDownLatch(1); 115 Intent intent = 116 new Intent(mContext, android.media.cts.MediaProjectionActivity.class) 117 .addFlags(FLAG_ACTIVITY_NEW_TASK); 118 119 if (config != null) { 120 intent.putExtra(android.media.cts.MediaProjectionActivity.EXTRA_MP_CONFIG, config); 121 } 122 if (launchCookie != null) { 123 intent.putExtra( 124 android.media.cts.MediaProjectionActivity.EXTRA_LAUNCH_COOKIE, launchCookie); 125 } 126 if (foregroundServiceClass != null) { 127 intent.putExtra( 128 android.media.cts.MediaProjectionActivity.EXTRA_FOREGROUND_SERVICE_CLASS, 129 foregroundServiceClass); 130 } 131 132 mMediaProjectionTrackerRule.mActivityScenario = ActivityScenario.launch(intent); 133 mMediaProjectionTrackerRule.mActivityScenario.onActivity( 134 activity -> { 135 mActivity = activity; 136 latch.countDown(); 137 }); 138 assertWithMessage("MediaProjectionActivity not started") 139 .that(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) 140 .isTrue(); 141 } 142 143 /** Start a MediaProjection session. */ startMediaProjection()144 public MediaProjection startMediaProjection() throws Exception { 145 return startMediaProjection(null, null, null, false); 146 } 147 148 /** Start a MediaProjection session (with a custom foregroundService class). */ startMediaProjection(String foregroundServiceClass)149 public MediaProjection startMediaProjection(String foregroundServiceClass) throws Exception { 150 return startMediaProjection(null, null, foregroundServiceClass, false); 151 } 152 153 /** Start a MediaProjection session (with a custom MediaProjectionConfig). */ startMediaProjection(MediaProjectionConfig config)154 public MediaProjection startMediaProjection(MediaProjectionConfig config) throws Exception { 155 return startMediaProjection(config, null, null, false); 156 } 157 158 /** Start a MediaProjection session for a specific LaunchCookie. */ startMediaProjection(ActivityOptions.LaunchCookie launchCookie)159 public MediaProjection startMediaProjection(ActivityOptions.LaunchCookie launchCookie) 160 throws Exception { 161 return startMediaProjection(null, launchCookie, null, false); 162 } 163 164 /** Start a MediaProjection session for a specific LaunchCookie. */ startMediaProjection(boolean skipDefaultCallback)165 public MediaProjection startMediaProjection(boolean skipDefaultCallback) throws Exception { 166 return startMediaProjection(null, null, null, skipDefaultCallback); 167 } 168 startMediaProjection( @ullable MediaProjectionConfig config, @Nullable ActivityOptions.LaunchCookie launchCookie, @Nullable String foregroundServiceClass, boolean skipDefaultCallback)169 private MediaProjection startMediaProjection( 170 @Nullable MediaProjectionConfig config, 171 @Nullable ActivityOptions.LaunchCookie launchCookie, 172 @Nullable String foregroundServiceClass, 173 boolean skipDefaultCallback) 174 throws Exception { 175 showMediaProjectionConsent(config, launchCookie, foregroundServiceClass); 176 mMediaProjectionTrackerRule.mMediaProjection = mActivity.waitForMediaProjection(); 177 mMediaProjectionTrackerRule.mActivityScenario.close(); 178 if (!skipDefaultCallback) { 179 // Register an empty callback. Tests that want to validate callback behavior can still 180 // register individual callbacks. 181 registerCallback(mEmptyMediaProjectionCallback); 182 } 183 return mMediaProjectionTrackerRule.mMediaProjection; 184 } 185 186 /** Register a MediaProjection.Callback. */ registerCallback(MediaProjection.Callback callback)187 public void registerCallback(MediaProjection.Callback callback) { 188 if (mMediaProjectionTrackerRule.mMediaProjection == null) { 189 throw new IllegalStateException("MediaProjection not yet started."); 190 } 191 mMediaProjectionTrackerRule.mCallbacks.add(callback); 192 mMediaProjectionTrackerRule.mMediaProjection.registerCallback(callback, mCallbackHandler); 193 } 194 195 /** Create a VirtualDisplay for the MediaProjection. */ createVirtualDisplay()196 public VirtualDisplay createVirtualDisplay() throws InterruptedException { 197 return createVirtualDisplay( 198 mMediaProjectionTrackerRule.mMediaProjection, RECORDING_WIDTH, RECORDING_HEIGHT); 199 } 200 201 /** Create a VirtualDisplay for a MediaProjection. */ createVirtualDisplay(MediaProjection mediaProjection)202 public VirtualDisplay createVirtualDisplay(MediaProjection mediaProjection) 203 throws InterruptedException { 204 return createVirtualDisplay(mediaProjection, RECORDING_WIDTH, RECORDING_HEIGHT); 205 } 206 207 /** Create a VirtualDisplay for a MediaProjection. */ createVirtualDisplay(int width, int height)208 public VirtualDisplay createVirtualDisplay(int width, int height) throws InterruptedException { 209 return createVirtualDisplay(mMediaProjectionTrackerRule.mMediaProjection, width, height); 210 } 211 212 /** Create a VirtualDisplay for the MediaProjection with specific dimensions. */ createVirtualDisplay( MediaProjection mediaProjection, int width, int height)213 public VirtualDisplay createVirtualDisplay( 214 MediaProjection mediaProjection, int width, int height) throws InterruptedException { 215 if (mediaProjection == null) { 216 throw new IllegalStateException("MediaProjection not yet started."); 217 } 218 219 CountDownLatch latch = new CountDownLatch(1); 220 DisplayManager dm = mContext.getSystemService(DisplayManager.class); 221 dm.registerDisplayListener( 222 new DisplayManager.DisplayListener() { 223 @Override 224 public void onDisplayAdded(int displayId) { 225 checkDisplayState(displayId); 226 } 227 228 @Override 229 public void onDisplayRemoved(int displayId) {} 230 231 @Override 232 public void onDisplayChanged(int displayId) { 233 checkDisplayState(displayId); 234 } 235 236 private void checkDisplayState(int displayId) { 237 Display display = dm.getDisplay(displayId); 238 if (display != null && display.getState() == Display.STATE_ON) { 239 latch.countDown(); 240 } 241 } 242 }, 243 null); 244 245 ImageReader imageReader = 246 ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, /* maxImages= */ 1); 247 mMediaProjectionTrackerRule.mImageReaders.add(imageReader); 248 CountDownLatch mScreenshotCountDownLatch = new CountDownLatch(1); 249 if (DEBUG_MODE) { 250 ScreenshotListener screenshotListener = 251 new ScreenshotListener(mTestName, mScreenshotCountDownLatch); 252 imageReader.setOnImageAvailableListener(screenshotListener, mCallbackHandler); 253 } 254 VirtualDisplay virtualDisplay = 255 mediaProjection.createVirtualDisplay( 256 mTestName, 257 width, 258 height, 259 RECORDING_DENSITY, 260 VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, 261 imageReader.getSurface(), 262 new VirtualDisplay.Callback() { 263 @Override 264 public void onStopped() { 265 super.onStopped(); 266 // VirtualDisplay stopped by the system; no more frames incoming. 267 // Must release VirtualDisplay. 268 mMediaProjectionTrackerRule.cleanupVirtualDisplay(); 269 } 270 }, 271 mCallbackHandler); 272 mMediaProjectionTrackerRule.mVirtualDisplays.add(virtualDisplay); 273 274 if (DEBUG_MODE) { 275 // wait until we've received a screenshot 276 try { 277 assertThat( 278 mScreenshotCountDownLatch.await( 279 SCREENSHOT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) 280 .isTrue(); 281 } catch (InterruptedException e) { 282 Log.e(TAG, e.toString()); 283 } 284 } 285 286 assertWithMessage("VirtualDisplay should be created and on") 287 .that(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) 288 .isTrue(); 289 return virtualDisplay; 290 } 291 292 /** Get the Activity hosting the MediaProjection consent flow. */ getActivity()293 public android.media.cts.MediaProjectionActivity getActivity() { 294 return mActivity; 295 } 296 297 /** Save MediaProjection's screenshots to the device to help debug test failures. */ 298 public static class ScreenshotListener implements ImageReader.OnImageAvailableListener { 299 private final CountDownLatch mCountDownLatch; 300 private final String mMethodName; 301 private int mCurrentScreenshot = 0; 302 // How often to save an image 303 private static final int SCREENSHOT_FREQUENCY = 5; 304 ScreenshotListener(@onNull String methodName, @NonNull CountDownLatch latch)305 public ScreenshotListener(@NonNull String methodName, @NonNull CountDownLatch latch) { 306 mMethodName = methodName; 307 mCountDownLatch = latch; 308 } 309 310 @Override onImageAvailable(ImageReader reader)311 public void onImageAvailable(ImageReader reader) { 312 if (mCurrentScreenshot % SCREENSHOT_FREQUENCY != 0) { 313 Log.d(TAG, "onImageAvailable - skip this one"); 314 return; 315 } 316 Log.d(TAG, "onImageAvailable - processing"); 317 mCountDownLatch.countDown(); 318 mCurrentScreenshot++; 319 320 final Image image = reader.acquireLatestImage(); 321 322 assertThat(image).isNotNull(); 323 324 final Image.Plane plane = image.getPlanes()[0]; 325 326 assertThat(plane).isNotNull(); 327 328 final int rowPadding = plane.getRowStride() - plane.getPixelStride() * image.getWidth(); 329 final Bitmap bitmap = 330 Bitmap.createBitmap( 331 /* width= */ image.getWidth() + rowPadding / plane.getPixelStride(), 332 /* height= */ image.getHeight(), 333 Bitmap.Config.ARGB_8888); 334 final ByteBuffer buffer = plane.getBuffer(); 335 336 assertThat(buffer).isNotNull(); 337 assertThat(bitmap).isNotNull(); // why null? 338 339 bitmap.copyPixelsFromBuffer(plane.getBuffer()); 340 assertThat(bitmap).isNotNull(); // why null? 341 342 try { 343 // save to virtual sdcard 344 final File outputDirectory = 345 new File(Environment.getExternalStorageDirectory(), "cts." + TAG); 346 Log.d(TAG, "Had to create the directory? " + outputDirectory.mkdir()); 347 final File screenshot = 348 new File( 349 outputDirectory, 350 mMethodName 351 + "_screenshot_" 352 + mCurrentScreenshot 353 + "_" 354 + System.currentTimeMillis() 355 + ".jpg"); 356 final FileOutputStream stream = new FileOutputStream(screenshot); 357 assertThat(stream).isNotNull(); 358 bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream); 359 stream.close(); 360 } catch (Exception e) { 361 Log.e(TAG, "Unable to write out screenshot", e); 362 } finally { 363 image.close(); 364 } 365 } 366 } 367 368 /** 369 * Internal rule that tracks the Active MediaProjection session and resources and ensures they 370 * are properly closed and released after the test. 371 */ 372 private static final class MediaProjectionTrackerRule extends ExternalResource { 373 374 final Set<MediaProjection.Callback> mCallbacks = new ArraySet<>(); 375 final Set<ImageReader> mImageReaders = new ArraySet<>(); 376 final Set<VirtualDisplay> mVirtualDisplays = new ArraySet<>(); 377 ActivityScenario<android.media.cts.MediaProjectionActivity> mActivityScenario; 378 MediaProjection mMediaProjection; 379 380 @Override after()381 protected void after() { 382 if (mMediaProjection != null) { 383 for (MediaProjection.Callback callback : mCallbacks) { 384 mMediaProjection.unregisterCallback(callback); 385 } 386 mMediaProjection.stop(); 387 mMediaProjection = null; 388 } 389 390 cleanupVirtualDisplay(); 391 392 if (mActivityScenario != null) { 393 mActivityScenario.close(); 394 } 395 } 396 cleanupVirtualDisplay()397 void cleanupVirtualDisplay() { 398 for (ImageReader imageReader : mImageReaders) { 399 imageReader.close(); 400 } 401 402 for (VirtualDisplay virtualDisplay : mVirtualDisplays) { 403 final Surface surface = virtualDisplay.getSurface(); 404 if (surface != null) { 405 surface.release(); 406 } 407 virtualDisplay.release(); 408 } 409 } 410 } 411 } 412