/*
* Copyright (C) 2013 DroidDriver committers
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.appium.droiddriver.scroll;
import android.annotation.TargetApi;
import android.app.UiAutomation;
import android.app.UiAutomation.AccessibilityEventFilter;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import java.util.concurrent.TimeoutException;
import io.appium.droiddriver.DroidDriver;
import io.appium.droiddriver.UiElement;
import io.appium.droiddriver.actions.SwipeAction;
import io.appium.droiddriver.exceptions.UnrecoverableException;
import io.appium.droiddriver.finders.Finder;
import io.appium.droiddriver.scroll.Direction.Axis;
import io.appium.droiddriver.scroll.Direction.DirectionConverter;
import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
import io.appium.droiddriver.util.Logs;
/**
* A {@link ScrollStepStrategy} that determines whether more scrolling is
* possible by checking the {@link AccessibilityEvent} returned by
* {@link android.app.UiAutomation}.
* <p>
* This implementation behaves just like the <a href=
* "http://developer.android.com/tools/help/uiautomator/UiScrollable.html"
* >UiScrollable</a> class. It may not work in all cases. For instance,
* sometimes {@code android.support.v4.widget.DrawerLayout} does not send
* correct {@link AccessibilityEvent}s after scrolling.
* </p>
*/
@TargetApi(18)
public class AccessibilityEventScrollStepStrategy implements ScrollStepStrategy {
/**
* Stores the data if we reached end at the last {@link #scroll}. If the data
* match when a new scroll is requested, we can return immediately.
*/
private static class EndData {
private Finder containerFinderAtEnd;
private PhysicalDirection directionAtEnd;
public boolean match(Finder containerFinder, PhysicalDirection direction) {
return containerFinderAtEnd == containerFinder && directionAtEnd == direction;
}
public void set(Finder containerFinder, PhysicalDirection direction) {
containerFinderAtEnd = containerFinder;
directionAtEnd = direction;
}
public void reset() {
set(null, null);
}
}
/**
* This filter allows us to grab the last accessibility event generated for a
* scroll up to {@code scrollEventTimeoutMillis}.
*/
private static class LastScrollEventFilter implements AccessibilityEventFilter {
private AccessibilityEvent lastEvent;
@Override
public boolean accept(AccessibilityEvent event) {
if ((event.getEventType() & AccessibilityEvent.TYPE_VIEW_SCROLLED) != 0) {
// Recycle the current last event.
if (lastEvent != null) {
lastEvent.recycle();
}
lastEvent = AccessibilityEvent.obtain(event);
}
// Return false to collect events until scrollEventTimeoutMillis has
// elapsed.
return false;
}
public AccessibilityEvent getLastEvent() {
return lastEvent;
}
}
private final UiAutomation uiAutomation;
private final long scrollEventTimeoutMillis;
private final DirectionConverter directionConverter;
private final EndData endData = new EndData();
public AccessibilityEventScrollStepStrategy(UiAutomation uiAutomation,
long scrollEventTimeoutMillis, DirectionConverter converter) {
this.uiAutomation = uiAutomation;
this.scrollEventTimeoutMillis = scrollEventTimeoutMillis;
this.directionConverter = converter;
}
@Override
public boolean scroll(DroidDriver driver, Finder containerFinder,
final PhysicalDirection direction) {
// Check if we've reached end after last scroll.
if (endData.match(containerFinder, direction)) {
return false;
}
AccessibilityEvent event = doScrollAndReturnEvent(driver.on(containerFinder), direction);
if (detectEnd(event, direction.axis())) {
endData.set(containerFinder, direction);
Logs.log(Log.DEBUG, "reached scroll end with event: " + event);
}
// Clean up the event after use.
if (event != null) {
event.recycle();
}
// Even if event == null, that does not mean scroll has no effect!
// Some views may not emit correct events when the content changed.
return true;
}
// Copied from UiAutomator.
// AdapterViews have indices we can use to check for the beginning.
protected boolean detectEnd(AccessibilityEvent event, Axis axis) {
if (event == null) {
return true;
}
if (event.getFromIndex() != -1 && event.getToIndex() != -1 && event.getItemCount() != -1) {
return event.getFromIndex() == 0 || (event.getItemCount() - 1) == event.getToIndex();
}
if (event.getScrollX() != -1 && event.getScrollY() != -1) {
if (axis == Axis.VERTICAL) {
return event.getScrollY() == 0 || event.getScrollY() == event.getMaxScrollY();
} else if (axis == Axis.HORIZONTAL) {
return event.getScrollX() == 0 || event.getScrollX() == event.getMaxScrollX();
}
}
// This case is different from UiAutomator.
return event.getFromIndex() == -1 && event.getToIndex() == -1 && event.getItemCount() == -1
&& event.getScrollX() == -1 && event.getScrollY() == -1;
}
@Override
public final DirectionConverter getDirectionConverter() {
return directionConverter;
}
@Override
public void beginScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
PhysicalDirection direction) {
endData.reset();
}
@Override
public void endScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
PhysicalDirection direction) {}
protected AccessibilityEvent doScrollAndReturnEvent(final UiElement container,
final PhysicalDirection direction) {
LastScrollEventFilter filter = new LastScrollEventFilter();
try {
uiAutomation.executeAndWaitForEvent(new Runnable() {
@Override
public void run() {
doScroll(container, direction);
}
}, filter, scrollEventTimeoutMillis);
} catch (IllegalStateException e) {
throw new UnrecoverableException(e);
} catch (TimeoutException e) {
// We expect this because LastScrollEventFilter.accept always returns
// false.
}
return filter.getLastEvent();
}
@Override
public void doScroll(final UiElement container, final PhysicalDirection direction) {
// We do not call container.scroll(direction) because it uses a SwipeAction
// with positive tTimeoutMillis. That path calls
// UiAutomation.executeAndWaitForEvent which clears the
// AccessibilityEvent Queue, preventing us from fetching the last
// accessibility event to determine if scrolling has finished.
container
.perform(new SwipeAction(direction, SwipeAction.getScrollSteps(), false /* drag */, 0L/* timeoutMillis */));
}
/**
* Some widgets may not always fire correct {@link AccessibilityEvent}.
* Detecting end by null event is safer (at the cost of a extra scroll) than
* examining indices.
*/
public static class NullAccessibilityEventScrollStepStrategy extends
AccessibilityEventScrollStepStrategy {
public NullAccessibilityEventScrollStepStrategy(UiAutomation uiAutomation,
long scrollEventTimeoutMillis, DirectionConverter converter) {
super(uiAutomation, scrollEventTimeoutMillis, converter);
}
@Override
protected boolean detectEnd(AccessibilityEvent event, Axis axis) {
return event == null;
}
}
}