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