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