/*
* Copyright (C) 2015 Google Inc.
*
* 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 com.googlecode.eyesfree.testing;
import android.Manifest.permission;
import android.accessibilityservice.AccessibilityService;
import android.accessibilityservice.AccessibilityServiceInfo;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcelable;
import android.os.SystemClock;
import android.provider.Settings;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.test.ActivityInstrumentationTestCase2;
import android.util.Log;
import android.view.View;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener;
import android.view.accessibility.AccessibilityNodeInfo;
import com.android.utils.LogUtils;
import com.android.utils.TestActivity;
import java.util.ArrayList;
import java.util.List;
public abstract class BaseAccessibilityInstrumentationTestCase
extends ActivityInstrumentationTestCase2<TestActivity> {
private static final String TOUCH_EXPLORATION_GRANTED_ACCESSIBILITY_SERVICES =
"touch_exploration_granted_accessibility_services";
// Used to obtain nodes from views.
private static final int NODE_INFO_EVENT_TYPE = AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED;
// Used to synchronize with the TalkBack event queue.
private static final int SYNC_EVENT_TYPE = AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED;
private static final Bundle SYNC_PARCELABLE = new Bundle();
private static final String SYNC_KEY = "key";
private static final double SYNC_VALUE = (Math.random() + 1.0);
static {
SYNC_PARCELABLE.putDouble(SYNC_KEY, SYNC_VALUE);
}
/** Maximum time to wait while attempting to obtain a service instance. */
private static final long OBTAIN_SERVICE_TIMEOUT = 2000;
/** Delay between retries while attempting to obtain a service instance. */
private static final long OBTAIN_SERVICE_RETRY = 100;
/** Maximum time to wait while changing the accessibility state. */
private static final long STATE_CHANGE_TIMEOUT = 3000;
/** Maximum time to wait for a specific event. */
private static final long OBTAIN_EVENT_TIMEOUT = 2000;
/** Maximum time to wait for the service to stop receiving events. */
private static final long NO_EVENTS_TIMEOUT = 2000;
/** Minimum time to wait for the service to stop receiving events. */
private static final long NO_EVENTS_DURATION = 500;
/** Fake view ID used to temporarily identify views. */
private static final int FAKE_VIEW_ID = Integer.MAX_VALUE;
/** List of recorded events. */
private final ArrayList<AccessibilityEvent> mEventCache = new ArrayList<>();
private final Object mAccessibilityStateLock = new Object();
private final Object mAccessibilityEventLock = new Object();
private AccessibilityManager mManager;
private boolean mAccessibilityState;
private boolean mRecordingEvents;
private long mLastEventTime;
protected Context mInsCtx;
protected Context mAppCtx;
public BaseAccessibilityInstrumentationTestCase() {
super(TestActivity.class);
}
@Override
protected void setUp() throws Exception {
super.setUp();
LogUtils.setLogLevel(Log.VERBOSE);
mInsCtx = getInstrumentation().getContext();
mAppCtx = getInstrumentation().getTargetContext();
mManager = (AccessibilityManager) mAppCtx.getSystemService(Context.ACCESSIBILITY_SERVICE);
assertEquals("Has WRITE_SECURE_SETTINGS permission (did you run \"adb shell pm grant "
+ mInsCtx.getPackageName() + " android.permission.WRITE_SECURE_SETTINGS\"?)",
PackageManager.PERMISSION_GRANTED,
mInsCtx.getPackageManager().checkPermission(
permission.WRITE_SECURE_SETTINGS, mInsCtx.getPackageName()));
AccessibilityService service = getService();
// Ensure the TalkBack and system accessibility states are in sync.
if ((service == null) || !mManager.isEnabled()) {
disableAllServices();
obtainNullTargetServiceSync();
enableTargetService();
obtainTargetServiceSync();
service = getService();
}
assertNotNull("Connected to service", service);
connectServiceListener();
}
@Override
protected void tearDown() throws Exception {
super.tearDown();
disconnectServiceListener();
disableAllServices();
}
protected abstract AccessibilityService getService();
protected abstract void enableTargetService();
protected abstract void connectServiceListener();
protected abstract void disconnectServiceListener();
/**
* Finds an {@link android.view.accessibility.AccessibilityNodeInfo} by View id in the active window.
* The search is performed from the root node.
*
* @param viewId The id of a View.
* @return An {@link android.view.accessibility.AccessibilityNodeInfo} if found, null otherwise.
*/
protected final AccessibilityNodeInfo findAccessibilityNodeInfoByViewIdInActiveWindow(
int viewId) {
startRecordingEvents();
final View view = getViewForId(viewId);
assertNotNull("Obtain view from activity", view);
final AccessibilityEvent event = AccessibilityEvent.obtain();
event.setEnabled(false);
event.setEventType(NODE_INFO_EVENT_TYPE);
event.setParcelableData(SYNC_PARCELABLE);
event.setSource(view);
// Sending the event through the manager sets the event time and
// may clear the source node. Only certain event types can be
// dispatched (see the framework's AccessibilityManagerService
// canDispatchAccessibilityEvent() method).
mManager.sendAccessibilityEvent(event);
final AccessibilityEvent syncedEvent = stopRecordingEventsAfter(mNodeInfoEventFilter);
assertNotNull("Synchronized event queue", syncedEvent);
final AccessibilityNodeInfo sourceNode = syncedEvent.getSource();
assertNotNull("Obtained source node from event", sourceNode);
return sourceNode;
}
protected void assertServiceIsInstalled(String servicePackage, String serviceName) {
final List<AccessibilityServiceInfo> services = mManager
.getInstalledAccessibilityServiceList();
for (AccessibilityServiceInfo service : services) {
final ServiceInfo serviceInfo = service.getResolveInfo().serviceInfo;
final String packageName = serviceInfo.applicationInfo.packageName;
if (servicePackage.equals(packageName) && serviceName.equals(serviceInfo.name)) {
return;
}
}
assertTrue("Service " + servicePackage + "/" + serviceName + " is not installed", false);
}
/**
* Calls {@link android.app.Activity#setContentView} with the specified
* layout resource and waits for a layout pass.
* <p>
* An initial layout pass is required for
* {@link android.view.accessibility.AccessibilityNodeInfo#isVisibleToUser} to return the correct
* value.
*
* @param layoutResID Resource ID to be passed to
* {@link android.app.Activity#setContentView}.
*/
protected void setContentView(final int layoutResID) {
final Activity activity = getActivity();
try {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
activity.setContentView(layoutResID);
}
});
waitForAccessibilityIdleSync();
waitForEventQueueSync();
} catch (Throwable e) {
e.printStackTrace();
}
}
/**
* Disables all accessibility services by clearing all accessibility-related
* settings (e.g. enabled services, accessibility enabled, etc.).
*/
protected void disableAllServices() {
final ContentResolver cr = mInsCtx.getContentResolver();
Settings.Secure.putString(cr, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, "");
// Granting touch exploration is only supported on JB and above.
Settings.Secure.putString(cr, TOUCH_EXPLORATION_GRANTED_ACCESSIBILITY_SERVICES, "");
Settings.Secure.putInt(cr, Settings.Secure.ACCESSIBILITY_ENABLED, 0);
Settings.Secure.putInt(cr, Settings.Secure.TOUCH_EXPLORATION_ENABLED, 0);
mAccessibilityState = assertAccessibilityStateSync(false);
}
/**
* Enables the specified accessibility service and turns on accessibility.
* <p> Setting {@code usesExploreByTouch} grants
* the service permission to turn on Explore by Touch. To enable the
* feature, you must request it in your service configuration.
*
* @param packageName The package containing the service to enable.
* @param className The class name of the service to enable.
* @param usesExploreByTouch Whether the service uses Explore by Touch.
*/
protected void enableService(String packageName, String className, boolean usesExploreByTouch) {
final String fullPackage = packageName + "/" + className;
final ContentResolver cr = mInsCtx.getContentResolver();
Settings.Secure.putString(cr, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, fullPackage);
String enabledService = Settings.Secure.getString(cr,
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);
assertEquals(fullPackage, enabledService);
// Granting touch exploration is only supported on JB and above.
if (usesExploreByTouch) {
Settings.Secure.putString(
cr, TOUCH_EXPLORATION_GRANTED_ACCESSIBILITY_SERVICES, fullPackage);
}
Settings.Secure.putInt(cr, Settings.Secure.ACCESSIBILITY_ENABLED, 1);
int accessibilityEnabled = Settings.Secure.getInt(cr,
Settings.Secure.ACCESSIBILITY_ENABLED, 0);
assertEquals(1, accessibilityEnabled);
mAccessibilityState = assertAccessibilityStateSync(true);
}
private boolean assertAccessibilityStateSync(boolean enabled) {
final boolean state;
final long startTime = SystemClock.uptimeMillis();
synchronized (mAccessibilityStateLock) {
mManager.addAccessibilityStateChangeListener(mStateListener);
try {
while (true) {
if (mManager.isEnabled() == enabled) {
break;
}
final long elapsed = (SystemClock.uptimeMillis() - startTime);
final long timeLeft = (STATE_CHANGE_TIMEOUT - elapsed);
if (timeLeft <= 0) {
break;
}
mAccessibilityStateLock.wait(timeLeft);
}
} catch (InterruptedException e) {
// Do nothing.
}
state = mManager.isEnabled();
assertEquals("Toggled accessibility state", enabled, state);
mManager.removeAccessibilityStateChangeListener(mStateListener);
}
LogUtils.log(this, Log.VERBOSE, "Took %d ms to enable accessibility",
(SystemClock.uptimeMillis() - startTime));
return state;
}
protected void waitForEventQueueSync() throws Throwable {
startRecordingEvents();
final AccessibilityEvent event = AccessibilityEvent.obtain();
event.setEnabled(false);
event.setEventType(SYNC_EVENT_TYPE);
event.setParcelableData(SYNC_PARCELABLE);
// Sending the event through the manager sets the event time and
// may clear the source node. Only certain event types can be
// dispatched (see the framework's AccessibilityManagerService
// canDispatchAccessibilityEvent() method).
mManager.sendAccessibilityEvent(event);
final AccessibilityEvent syncedEvent = stopRecordingEventsAfter(mSyncEventFilter);
assertNotNull("Synchronized event queue", syncedEvent);
}
/**
* Ensures that {@link #NO_EVENTS_DURATION} milliseconds have passed since
* the last accessibility event.
*/
protected void waitForAccessibilityIdleSync() {
boolean hasIdleSync = false;
final long startTime = SystemClock.uptimeMillis();
synchronized (mAccessibilityEventLock) {
try {
// Reset the event time to now so that we catch queued events.
mLastEventTime = SystemClock.uptimeMillis();
while (true) {
final long eventTimeElapsed = (SystemClock.uptimeMillis() - mLastEventTime);
final long eventTimeLeft = (NO_EVENTS_DURATION - eventTimeElapsed);
if (eventTimeLeft <= 0) {
hasIdleSync = true;
break;
}
final long timeElapsed = (SystemClock.uptimeMillis() - startTime);
final long timeLeft = (NO_EVENTS_TIMEOUT - timeElapsed);
if (timeLeft <= 0) {
break;
}
final long timeToWait = Math.min(timeLeft, eventTimeLeft);
mAccessibilityEventLock.wait(timeToWait);
}
} catch (InterruptedException e) {
// Do nothing.
}
assertTrue("Accessibility events idle for " + NO_EVENTS_DURATION + " ms", hasIdleSync);
}
LogUtils.log(this, Log.VERBOSE, "Took %d ms to sync accessibility idle state",
(SystemClock.uptimeMillis() - startTime));
}
/**
* Returns the {@link android.view.View} for the specified id.
*
* @param viewId The id of the view to obtain.
* @return The view, or {@code null}.
*/
protected View getViewForId(int viewId) {
if (viewId <= 0) {
return null;
}
final View view = getActivity().findViewById(viewId);
assertNotNull("Obtain view with id " + viewId, view);
return view;
}
/**
* Returns the {@link android.support.v4.view.accessibility.AccessibilityNodeInfoCompat} for a specific view, or
* {@code null} if the view is invalid or an error occurred while obtaining
* the info.
*
* @param viewId The id of the view whose node to obtain.
* @return The view's node info, or {@code null}.
*/
protected AccessibilityNodeInfoCompat getNodeForId(int viewId) {
if (viewId <= 0) {
return null;
}
final AccessibilityNodeInfo node = findAccessibilityNodeInfoByViewIdInActiveWindow(viewId);
assertNotNull("Obtain node from view id " + viewId, node);
return new AccessibilityNodeInfoCompat(node);
}
/**
* Returns the {@link android.support.v4.view.accessibility.AccessibilityNodeInfoCompat} for a specific view, or
* {@code null} if the view is invalid or an error occurred while obtaining
* the info.
*
* @param view The view whose node to obtain.
* @return The view's node info, or {@code null}.
*/
protected AccessibilityNodeInfoCompat getNodeForView(View view) {
if (view == null) {
return null;
}
final int realViewId = view.getId();
view.setId(FAKE_VIEW_ID);
final AccessibilityNodeInfoCompat node = getNodeForId(FAKE_VIEW_ID);
view.setId(realViewId);
return node;
}
protected void startRecordingEvents() {
synchronized (mEventCache) {
mEventCache.clear();
mRecordingEvents = true;
}
}
protected AccessibilityEvent stopRecordingEventsAfter(EventFilter filter) {
final long startTime = SystemClock.uptimeMillis();
synchronized (mEventCache) {
try {
int currentIndex = 0;
while (true) {
// Check all events starting from the current index.
while (currentIndex < mEventCache.size()) {
final AccessibilityEvent event = mEventCache.get(currentIndex);
if (filter.accept(event)) {
mRecordingEvents = false;
return event;
}
currentIndex++;
}
final long elapsed = (SystemClock.uptimeMillis() - startTime);
final long timeLeft = (OBTAIN_EVENT_TIMEOUT - elapsed);
if (timeLeft <= 0) {
break;
}
mEventCache.wait(timeLeft);
}
mRecordingEvents = false;
} catch (InterruptedException e) {
// Do nothing.
}
}
return null;
}
/**
* Attempts to obtain a null instance of {@link TestAccessibilityService}.
* <p>
* May block for up to {@link #OBTAIN_SERVICE_TIMEOUT} seconds, and may
* return {@code null} if the service is not running.
*/
private boolean obtainNullTargetServiceSync() {
boolean success = false;
final long startTime = SystemClock.uptimeMillis();
try {
while (true) {
final AccessibilityService service = getService();
if (service == null) {
break;
}
final long timeElapsed = (SystemClock.uptimeMillis() - startTime);
final long timeLeft = (OBTAIN_SERVICE_TIMEOUT - timeElapsed);
if (timeLeft <= 0) {
break;
}
final long timeToWait = Math.min(OBTAIN_SERVICE_RETRY, timeLeft);
Thread.sleep(timeToWait);
}
} catch (InterruptedException e) {
// Do nothing.
}
LogUtils.log(this, Log.VERBOSE, "Took %d ms to obtain null service",
(SystemClock.uptimeMillis() - startTime));
return success;
}
/**
* Attempts to obtain an instance of {@link TestAccessibilityService}.
* <p>
* May block for up to {@link #OBTAIN_SERVICE_TIMEOUT} seconds, and may
* return {@code null} if the service is not running.
*/
private boolean obtainTargetServiceSync() {
boolean success = false;
final long startTime = SystemClock.uptimeMillis();
try {
while (true) {
final AccessibilityService service = getService();
if (service != null) {
break;
}
final long timeElapsed = (SystemClock.uptimeMillis() - startTime);
final long timeLeft = (OBTAIN_SERVICE_TIMEOUT - timeElapsed);
if (timeLeft <= 0) {
break;
}
final long timeToWait = Math.min(OBTAIN_SERVICE_RETRY, timeLeft);
Thread.sleep(timeToWait);
}
} catch (InterruptedException e) {
// Do nothing.
}
LogUtils.log(this, Log.VERBOSE, "Took %d ms to obtain service",
(SystemClock.uptimeMillis() - startTime));
return success;
}
protected void onEventReceived(AccessibilityEvent event) {
synchronized (mAccessibilityEventLock) {
mLastEventTime = SystemClock.uptimeMillis();
}
synchronized (mEventCache) {
if (mRecordingEvents) {
mEventCache.add(AccessibilityEvent.obtain(event));
mEventCache.notifyAll();
}
}
}
/** Event filter used to synchronize with the TalkBack event queue. */
private final EventFilter mNodeInfoEventFilter = new EventFilter() {
@Override
public boolean accept(AccessibilityEvent event) {
if (event.getEventType() != NODE_INFO_EVENT_TYPE) {
return false;
}
final Parcelable parcel = event.getParcelableData();
return parcel instanceof Bundle
&& (((Bundle) parcel).getDouble(SYNC_KEY) == SYNC_VALUE);
}
};
/** Event filter used to synchronize with the TalkBack event queue. */
private final EventFilter mSyncEventFilter = new EventFilter() {
@Override
public boolean accept(AccessibilityEvent event) {
if (event.getEventType() != SYNC_EVENT_TYPE) {
return false;
}
final Parcelable parcel = event.getParcelableData();
return parcel instanceof Bundle
&& (((Bundle) parcel).getDouble(SYNC_KEY) == SYNC_VALUE);
}
};
private final AccessibilityStateChangeListener
mStateListener = new AccessibilityStateChangeListener() {
@Override
public void onAccessibilityStateChanged(boolean enabled) {
synchronized (mAccessibilityStateLock) {
mAccessibilityState = enabled;
mAccessibilityStateLock.notifyAll();
}
}
};
}