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