com.googlecode.eyesfree.testing.BaseAccessibilityInstrumentationTestCase.java Source code

Java tutorial

Introduction

Here is the source code for com.googlecode.eyesfree.testing.BaseAccessibilityInstrumentationTestCase.java

Source

/*
 * 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();
            }
        }
    };
}