1 /* 2 * Copyright (C) 2013 DroidDriver committers 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 io.appium.droiddriver.scroll; 17 18 import android.annotation.TargetApi; 19 import android.app.UiAutomation; 20 import android.app.UiAutomation.AccessibilityEventFilter; 21 import android.util.Log; 22 import android.view.accessibility.AccessibilityEvent; 23 24 import java.util.concurrent.TimeoutException; 25 26 import io.appium.droiddriver.DroidDriver; 27 import io.appium.droiddriver.UiElement; 28 import io.appium.droiddriver.actions.SwipeAction; 29 import io.appium.droiddriver.exceptions.UnrecoverableException; 30 import io.appium.droiddriver.finders.Finder; 31 import io.appium.droiddriver.scroll.Direction.Axis; 32 import io.appium.droiddriver.scroll.Direction.DirectionConverter; 33 import io.appium.droiddriver.scroll.Direction.PhysicalDirection; 34 import io.appium.droiddriver.util.Logs; 35 36 /** 37 * A {@link ScrollStepStrategy} that determines whether more scrolling is 38 * possible by checking the {@link AccessibilityEvent} returned by 39 * {@link android.app.UiAutomation}. 40 * <p> 41 * This implementation behaves just like the <a href= 42 * "http://developer.android.com/tools/help/uiautomator/UiScrollable.html" 43 * >UiScrollable</a> class. It may not work in all cases. For instance, 44 * sometimes {@code android.support.v4.widget.DrawerLayout} does not send 45 * correct {@link AccessibilityEvent}s after scrolling. 46 * </p> 47 */ 48 @TargetApi(18) 49 public class AccessibilityEventScrollStepStrategy implements ScrollStepStrategy { 50 /** 51 * Stores the data if we reached end at the last {@link #scroll}. If the data 52 * match when a new scroll is requested, we can return immediately. 53 */ 54 private static class EndData { 55 private Finder containerFinderAtEnd; 56 private PhysicalDirection directionAtEnd; 57 58 public boolean match(Finder containerFinder, PhysicalDirection direction) { 59 return containerFinderAtEnd == containerFinder && directionAtEnd == direction; 60 } 61 62 public void set(Finder containerFinder, PhysicalDirection direction) { 63 containerFinderAtEnd = containerFinder; 64 directionAtEnd = direction; 65 } 66 67 public void reset() { 68 set(null, null); 69 } 70 } 71 72 /** 73 * This filter allows us to grab the last accessibility event generated for a 74 * scroll up to {@code scrollEventTimeoutMillis}. 75 */ 76 private static class LastScrollEventFilter implements AccessibilityEventFilter { 77 private AccessibilityEvent lastEvent; 78 79 @Override 80 public boolean accept(AccessibilityEvent event) { 81 if ((event.getEventType() & AccessibilityEvent.TYPE_VIEW_SCROLLED) != 0) { 82 // Recycle the current last event. 83 if (lastEvent != null) { 84 lastEvent.recycle(); 85 } 86 lastEvent = AccessibilityEvent.obtain(event); 87 } 88 // Return false to collect events until scrollEventTimeoutMillis has 89 // elapsed. 90 return false; 91 } 92 93 public AccessibilityEvent getLastEvent() { 94 return lastEvent; 95 } 96 } 97 98 private final UiAutomation uiAutomation; 99 private final long scrollEventTimeoutMillis; 100 private final DirectionConverter directionConverter; 101 private final EndData endData = new EndData(); 102 103 public AccessibilityEventScrollStepStrategy(UiAutomation uiAutomation, 104 long scrollEventTimeoutMillis, DirectionConverter converter) { 105 this.uiAutomation = uiAutomation; 106 this.scrollEventTimeoutMillis = scrollEventTimeoutMillis; 107 this.directionConverter = converter; 108 } 109 110 @Override 111 public boolean scroll(DroidDriver driver, Finder containerFinder, 112 final PhysicalDirection direction) { 113 // Check if we've reached end after last scroll. 114 if (endData.match(containerFinder, direction)) { 115 return false; 116 } 117 118 AccessibilityEvent event = doScrollAndReturnEvent(driver.on(containerFinder), direction); 119 if (detectEnd(event, direction.axis())) { 120 endData.set(containerFinder, direction); 121 Logs.log(Log.DEBUG, "reached scroll end with event: " + event); 122 } 123 124 // Clean up the event after use. 125 if (event != null) { 126 event.recycle(); 127 } 128 129 // Even if event == null, that does not mean scroll has no effect! 130 // Some views may not emit correct events when the content changed. 131 return true; 132 } 133 134 // Copied from UiAutomator. 135 // AdapterViews have indices we can use to check for the beginning. 136 protected boolean detectEnd(AccessibilityEvent event, Axis axis) { 137 if (event == null) { 138 return true; 139 } 140 141 if (event.getFromIndex() != -1 && event.getToIndex() != -1 && event.getItemCount() != -1) { 142 return event.getFromIndex() == 0 || (event.getItemCount() - 1) == event.getToIndex(); 143 } 144 if (event.getScrollX() != -1 && event.getScrollY() != -1) { 145 if (axis == Axis.VERTICAL) { 146 return event.getScrollY() == 0 || event.getScrollY() == event.getMaxScrollY(); 147 } else if (axis == Axis.HORIZONTAL) { 148 return event.getScrollX() == 0 || event.getScrollX() == event.getMaxScrollX(); 149 } 150 } 151 152 // This case is different from UiAutomator. 153 return event.getFromIndex() == -1 && event.getToIndex() == -1 && event.getItemCount() == -1 154 && event.getScrollX() == -1 && event.getScrollY() == -1; 155 } 156 157 @Override 158 public final DirectionConverter getDirectionConverter() { 159 return directionConverter; 160 } 161 162 @Override 163 public void beginScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder, 164 PhysicalDirection direction) { 165 endData.reset(); 166 } 167 168 @Override 169 public void endScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder, 170 PhysicalDirection direction) {} 171 172 protected AccessibilityEvent doScrollAndReturnEvent(final UiElement container, 173 final PhysicalDirection direction) { 174 LastScrollEventFilter filter = new LastScrollEventFilter(); 175 try { 176 uiAutomation.executeAndWaitForEvent(new Runnable() { 177 @Override 178 public void run() { 179 doScroll(container, direction); 180 } 181 }, filter, scrollEventTimeoutMillis); 182 } catch (IllegalStateException e) { 183 throw new UnrecoverableException(e); 184 } catch (TimeoutException e) { 185 // We expect this because LastScrollEventFilter.accept always returns 186 // false. 187 } 188 return filter.getLastEvent(); 189 } 190 191 @Override 192 public void doScroll(final UiElement container, final PhysicalDirection direction) { 193 // We do not call container.scroll(direction) because it uses a SwipeAction 194 // with positive tTimeoutMillis. That path calls 195 // UiAutomation.executeAndWaitForEvent which clears the 196 // AccessibilityEvent Queue, preventing us from fetching the last 197 // accessibility event to determine if scrolling has finished. 198 container 199 .perform(new SwipeAction(direction, SwipeAction.getScrollSteps(), false /* drag */, 0L/* timeoutMillis */)); 200 } 201 202 /** 203 * Some widgets may not always fire correct {@link AccessibilityEvent}. 204 * Detecting end by null event is safer (at the cost of a extra scroll) than 205 * examining indices. 206 */ 207 public static class NullAccessibilityEventScrollStepStrategy extends 208 AccessibilityEventScrollStepStrategy { 209 210 public NullAccessibilityEventScrollStepStrategy(UiAutomation uiAutomation, 211 long scrollEventTimeoutMillis, DirectionConverter converter) { 212 super(uiAutomation, scrollEventTimeoutMillis, converter); 213 } 214 215 @Override 216 protected boolean detectEnd(AccessibilityEvent event, Axis axis) { 217 return event == null; 218 } 219 } 220 } 221