1 package org.robolectric.shadows; 2 3 import static android.app.UiAutomation.ROTATION_FREEZE_0; 4 import static android.app.UiAutomation.ROTATION_FREEZE_180; 5 import static android.os.Build.VERSION_CODES.TIRAMISU; 6 import static android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; 7 import static android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE; 8 import static android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL; 9 import static android.view.WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH; 10 import static com.google.common.base.Preconditions.checkState; 11 import static com.google.common.collect.Sets.newConcurrentHashSet; 12 import static java.util.Comparator.comparingInt; 13 import static java.util.stream.Collectors.toList; 14 import static java.util.stream.Collectors.toSet; 15 import static org.robolectric.Shadows.shadowOf; 16 17 import android.app.Activity; 18 import android.app.UiAutomation; 19 import android.content.ContentResolver; 20 import android.content.res.Configuration; 21 import android.content.res.Resources; 22 import android.graphics.Bitmap; 23 import android.graphics.Canvas; 24 import android.graphics.Paint; 25 import android.graphics.Point; 26 import android.os.IBinder; 27 import android.provider.Settings; 28 import android.view.Display; 29 import android.view.InputEvent; 30 import android.view.KeyEvent; 31 import android.view.MotionEvent; 32 import android.view.View; 33 import android.view.ViewRootImpl; 34 import android.view.WindowManager; 35 import android.view.WindowManagerGlobal; 36 import androidx.test.runner.lifecycle.ActivityLifecycleMonitor; 37 import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; 38 import androidx.test.runner.lifecycle.Stage; 39 import com.google.common.collect.ImmutableList; 40 import java.util.ArrayList; 41 import java.util.Arrays; 42 import java.util.List; 43 import java.util.Set; 44 import java.util.concurrent.FutureTask; 45 import java.util.concurrent.atomic.AtomicBoolean; 46 import java.util.function.Predicate; 47 import org.robolectric.RuntimeEnvironment; 48 import org.robolectric.annotation.Implementation; 49 import org.robolectric.annotation.Implements; 50 import org.robolectric.util.ReflectionHelpers; 51 52 /** Shadow for {@link UiAutomation}. */ 53 @Implements(value = UiAutomation.class) 54 public class ShadowUiAutomation { 55 56 private static final Predicate<Root> IS_FOCUSABLE = hasLayoutFlag(FLAG_NOT_FOCUSABLE).negate(); 57 private static final Predicate<Root> IS_TOUCHABLE = hasLayoutFlag(FLAG_NOT_TOUCHABLE).negate(); 58 private static final Predicate<Root> IS_TOUCH_MODAL = 59 IS_FOCUSABLE.and(hasLayoutFlag(FLAG_NOT_TOUCH_MODAL).negate()); 60 private static final Predicate<Root> WATCH_TOUCH_OUTSIDE = 61 IS_TOUCH_MODAL.negate().and(hasLayoutFlag(FLAG_WATCH_OUTSIDE_TOUCH)); 62 63 /** 64 * Sets the animation scale, see {@link UiAutomation#setAnimationScale(float)}. Provides backwards 65 * compatible access to SDKs < T. 66 */ setAnimationScaleCompat(float scale)67 public static void setAnimationScaleCompat(float scale) { 68 ContentResolver cr = RuntimeEnvironment.getApplication().getContentResolver(); 69 Settings.Global.putFloat(cr, Settings.Global.ANIMATOR_DURATION_SCALE, scale); 70 Settings.Global.putFloat(cr, Settings.Global.TRANSITION_ANIMATION_SCALE, scale); 71 Settings.Global.putFloat(cr, Settings.Global.WINDOW_ANIMATION_SCALE, scale); 72 } 73 74 @Implementation(minSdk = TIRAMISU) setAnimationScale(float scale)75 protected void setAnimationScale(float scale) { 76 setAnimationScaleCompat(scale); 77 } 78 79 @Implementation setRotation(int rotation)80 protected boolean setRotation(int rotation) { 81 AtomicBoolean result = new AtomicBoolean(false); 82 ShadowInstrumentation.runOnMainSyncNoIdle( 83 () -> { 84 if (rotation == UiAutomation.ROTATION_FREEZE_CURRENT 85 || rotation == UiAutomation.ROTATION_UNFREEZE) { 86 result.set(true); 87 return; 88 } 89 Display display = ShadowDisplay.getDefaultDisplay(); 90 int currentRotation = display.getRotation(); 91 boolean isRotated = 92 (rotation == ROTATION_FREEZE_0 || rotation == ROTATION_FREEZE_180) 93 != (currentRotation == ROTATION_FREEZE_0 94 || currentRotation == ROTATION_FREEZE_180); 95 shadowOf(display).setRotation(rotation); 96 if (isRotated) { 97 int currentOrientation = Resources.getSystem().getConfiguration().orientation; 98 String rotationQualifier = 99 "+" + (currentOrientation == Configuration.ORIENTATION_PORTRAIT ? "land" : "port"); 100 ShadowDisplayManager.changeDisplay(display.getDisplayId(), rotationQualifier); 101 RuntimeEnvironment.setQualifiers(rotationQualifier); 102 } 103 result.set(true); 104 }); 105 return result.get(); 106 } 107 108 @Implementation throwIfNotConnectedLocked()109 protected void throwIfNotConnectedLocked() {} 110 111 @Implementation takeScreenshot()112 protected Bitmap takeScreenshot() throws Exception { 113 if (!ShadowView.useRealGraphics()) { 114 return null; 115 } 116 117 FutureTask<Bitmap> screenshotTask = 118 new FutureTask<>( 119 () -> { 120 Point displaySize = new Point(); 121 ShadowDisplay.getDefaultDisplay().getRealSize(displaySize); 122 Bitmap screenshot = 123 Bitmap.createBitmap(displaySize.x, displaySize.y, Bitmap.Config.ARGB_8888); 124 Canvas screenshotCanvas = new Canvas(screenshot); 125 Paint paint = new Paint(); 126 for (Root root : getViewRoots().reverse()) { 127 View rootView = root.getRootView(); 128 if (rootView.getWidth() <= 0 || rootView.getHeight() <= 0) { 129 continue; 130 } 131 Bitmap window = 132 Bitmap.createBitmap( 133 rootView.getWidth(), rootView.getHeight(), Bitmap.Config.ARGB_8888); 134 if (HardwareRenderingScreenshot.canTakeScreenshot(rootView)) { 135 HardwareRenderingScreenshot.takeScreenshot(rootView, window); 136 } else { 137 Canvas windowCanvas = new Canvas(window); 138 rootView.draw(windowCanvas); 139 } 140 screenshotCanvas.drawBitmap( 141 window, root.locationOnScreen.x, root.locationOnScreen.y, paint); 142 } 143 return screenshot; 144 }); 145 146 ShadowInstrumentation.runOnMainSyncNoIdle(screenshotTask); 147 return screenshotTask.get(); 148 } 149 150 /** 151 * Injects a motion event into the appropriate window, see {@link 152 * UiAutomation#injectInputEvent(InputEvent, boolean)}. This can be used through the {@link 153 * UiAutomation} API, this method is provided for backwards compatibility with SDK < 18. 154 */ injectInputEvent(InputEvent event)155 public static boolean injectInputEvent(InputEvent event) { 156 AtomicBoolean result = new AtomicBoolean(false); 157 ShadowInstrumentation.runOnMainSyncNoIdle( 158 () -> { 159 if (event instanceof MotionEvent) { 160 result.set(injectMotionEvent((MotionEvent) event)); 161 } else if (event instanceof KeyEvent) { 162 result.set(injectKeyEvent((KeyEvent) event)); 163 } else { 164 throw new IllegalArgumentException("Unrecognized event type: " + event); 165 } 166 }); 167 return result.get(); 168 } 169 170 @Implementation injectInputEvent(InputEvent event, boolean sync)171 protected boolean injectInputEvent(InputEvent event, boolean sync) { 172 return injectInputEvent(event); 173 } 174 injectMotionEvent(MotionEvent event)175 private static boolean injectMotionEvent(MotionEvent event) { 176 // TODO(paulsowden): The real implementation will send a full event stream (a touch down 177 // followed by a series of moves, etc) to the same window/root even if the subsequent events 178 // leave the window bounds, and will split pointer down events based on the window flags. 179 // This will be necessary to support more sophisticated multi-window use cases. 180 181 List<Root> touchableRoots = getViewRoots().stream().filter(IS_TOUCHABLE).collect(toList()); 182 for (int i = 0; i < touchableRoots.size(); i++) { 183 Root root = touchableRoots.get(i); 184 if (i == touchableRoots.size() - 1 || root.isTouchModal() || root.isTouchInside(event)) { 185 event.offsetLocation(-root.locationOnScreen.x, -root.locationOnScreen.y); 186 root.getRootView().dispatchTouchEvent(event); 187 event.offsetLocation(root.locationOnScreen.x, root.locationOnScreen.y); 188 break; 189 } else if (event.getActionMasked() == MotionEvent.ACTION_DOWN && root.watchTouchOutside()) { 190 MotionEvent outsideEvent = MotionEvent.obtain(event); 191 outsideEvent.setAction(MotionEvent.ACTION_OUTSIDE); 192 outsideEvent.offsetLocation(-root.locationOnScreen.x, -root.locationOnScreen.y); 193 root.getRootView().dispatchTouchEvent(outsideEvent); 194 outsideEvent.recycle(); 195 } 196 } 197 return true; 198 } 199 injectKeyEvent(KeyEvent event)200 private static boolean injectKeyEvent(KeyEvent event) { 201 getViewRoots().stream() 202 .filter(IS_FOCUSABLE) 203 .findFirst() 204 .ifPresent(root -> root.getRootView().dispatchKeyEvent(event)); 205 return true; 206 } 207 getViewRoots()208 private static ImmutableList<Root> getViewRoots() { 209 List<ViewRootImpl> viewRootImpls = getViewRootImpls(); 210 List<WindowManager.LayoutParams> params = getRootLayoutParams(); 211 checkState( 212 params.size() == viewRootImpls.size(), 213 "number params is not consistent with number of view roots!"); 214 Set<IBinder> startedActivityTokens = getStartedActivityTokens(); 215 ArrayList<Root> roots = new ArrayList<>(); 216 for (int i = 0; i < viewRootImpls.size(); i++) { 217 Root root = new Root(viewRootImpls.get(i), params.get(i), i); 218 // TODO: Should we also filter out sub-windows of non-started application windows? 219 if (root.getType() != WindowManager.LayoutParams.TYPE_BASE_APPLICATION 220 || startedActivityTokens.contains(root.impl.getView().getApplicationWindowToken())) { 221 roots.add(root); 222 } 223 } 224 roots.sort( 225 comparingInt(Root::getType) 226 .reversed() 227 .thenComparing(comparingInt(Root::getIndex).reversed())); 228 return ImmutableList.copyOf(roots); 229 } 230 231 @SuppressWarnings("unchecked") getViewRootImpls()232 private static List<ViewRootImpl> getViewRootImpls() { 233 Object windowManager = getViewRootsContainer(); 234 Object viewRootsObj = ReflectionHelpers.getField(windowManager, "mRoots"); 235 Class<?> viewRootsClass = viewRootsObj.getClass(); 236 if (ViewRootImpl[].class.isAssignableFrom(viewRootsClass)) { 237 return Arrays.asList((ViewRootImpl[]) viewRootsObj); 238 } else if (List.class.isAssignableFrom(viewRootsClass)) { 239 return (List<ViewRootImpl>) viewRootsObj; 240 } else { 241 throw new IllegalStateException( 242 "WindowManager.mRoots is an unknown type " + viewRootsClass.getName()); 243 } 244 } 245 246 @SuppressWarnings("unchecked") getRootLayoutParams()247 private static List<WindowManager.LayoutParams> getRootLayoutParams() { 248 Object windowManager = getViewRootsContainer(); 249 Object paramsObj = ReflectionHelpers.getField(windowManager, "mParams"); 250 Class<?> paramsClass = paramsObj.getClass(); 251 if (WindowManager.LayoutParams[].class.isAssignableFrom(paramsClass)) { 252 return Arrays.asList((WindowManager.LayoutParams[]) paramsObj); 253 } else if (List.class.isAssignableFrom(paramsClass)) { 254 return (List<WindowManager.LayoutParams>) paramsObj; 255 } else { 256 throw new IllegalStateException( 257 "WindowManager.mParams is an unknown type " + paramsClass.getName()); 258 } 259 } 260 getViewRootsContainer()261 private static Object getViewRootsContainer() { 262 return WindowManagerGlobal.getInstance(); 263 } 264 getStartedActivityTokens()265 private static Set<IBinder> getStartedActivityTokens() { 266 Set<Activity> startedActivities = newConcurrentHashSet(); 267 ShadowInstrumentation.runOnMainSyncNoIdle( 268 () -> { 269 ActivityLifecycleMonitor monitor = ActivityLifecycleMonitorRegistry.getInstance(); 270 startedActivities.addAll(monitor.getActivitiesInStage(Stage.STARTED)); 271 startedActivities.addAll(monitor.getActivitiesInStage(Stage.RESUMED)); 272 }); 273 274 return startedActivities.stream() 275 .map(activity -> activity.getWindow().getDecorView().getApplicationWindowToken()) 276 .collect(toSet()); 277 } 278 hasLayoutFlag(int flag)279 private static Predicate<Root> hasLayoutFlag(int flag) { 280 return root -> (root.params.flags & flag) == flag; 281 } 282 283 private static final class Root { 284 final ViewRootImpl impl; 285 final WindowManager.LayoutParams params; 286 final int index; 287 final Point locationOnScreen; 288 Root(ViewRootImpl impl, WindowManager.LayoutParams params, int index)289 Root(ViewRootImpl impl, WindowManager.LayoutParams params, int index) { 290 this.impl = impl; 291 this.params = params; 292 this.index = index; 293 294 int[] coords = new int[2]; 295 getRootView().getLocationOnScreen(coords); 296 locationOnScreen = new Point(coords[0], coords[1]); 297 } 298 getIndex()299 int getIndex() { 300 return index; 301 } 302 getType()303 int getType() { 304 return params.type; 305 } 306 getRootView()307 View getRootView() { 308 return impl.getView(); 309 } 310 isTouchInside(MotionEvent event)311 boolean isTouchInside(MotionEvent event) { 312 int index = event.getActionIndex(); 313 return event.getX(index) >= locationOnScreen.x 314 && event.getX(index) <= locationOnScreen.x + impl.getView().getWidth() 315 && event.getY(index) >= locationOnScreen.y 316 && event.getY(index) <= locationOnScreen.y + impl.getView().getHeight(); 317 } 318 isTouchModal()319 boolean isTouchModal() { 320 return IS_TOUCH_MODAL.test(this); 321 } 322 watchTouchOutside()323 boolean watchTouchOutside() { 324 return WATCH_TOUCH_OUTSIDE.test(this); 325 } 326 } 327 } 328