root/content/public/android/java/src/org/chromium/content/browser/accessibility/AccessibilityInjector.java

/* [<][>][^][v][top][bottom][index][help] */

DEFINITIONS

This source file includes following definitions.
  1. newInstance
  2. injectAccessibilityScriptIntoPage
  3. addOrRemoveAccessibilityApisIfNecessary
  4. accessibilityIsAvailable
  5. setScriptEnabled
  6. didStartLoading
  7. didStopLoading
  8. onPageLostFocus
  9. onInitializeAccessibilityNodeInfo
  10. supportsAccessibilityAction
  11. performAccessibilityAction
  12. addAccessibilityApis
  13. removeAccessibilityApis
  14. getAxsUrlParameterValue
  15. getScreenReaderInjectingJs
  16. getAccessibilityManager
  17. SuppressWarnings
  18. hasVibrator
  19. SuppressWarnings
  20. vibrate
  21. SuppressWarnings
  22. vibrate
  23. SuppressWarnings
  24. cancel
  25. SuppressWarnings
  26. isSpeaking
  27. SuppressWarnings
  28. speak
  29. SuppressWarnings
  30. stop
  31. SuppressWarnings
  32. braille
  33. SuppressWarnings
  34. shutdownInternal

// Copyright 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.content.browser.accessibility;

import android.accessibilityservice.AccessibilityServiceInfo;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Vibrator;
import android.speech.tts.TextToSpeech;
import android.util.Log;
import android.view.View;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityNodeInfo;

import com.googlecode.eyesfree.braille.selfbraille.SelfBrailleClient;
import com.googlecode.eyesfree.braille.selfbraille.WriteData;

import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import org.chromium.base.CommandLine;
import org.chromium.content.browser.ContentViewCore;
import org.chromium.content.browser.JavascriptInterface;
import org.chromium.content.browser.WebContentsObserverAndroid;
import org.chromium.content.common.ContentSwitches;
import org.json.JSONException;
import org.json.JSONObject;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;

/**
 * Responsible for accessibility injection and management of a {@link ContentViewCore}.
 */
public class AccessibilityInjector extends WebContentsObserverAndroid {
    private static final String TAG = "AccessibilityInjector";

    // The ContentView this injector is responsible for managing.
    protected ContentViewCore mContentViewCore;

    // The Java objects that are exposed to JavaScript
    private TextToSpeechWrapper mTextToSpeech;
    private VibratorWrapper mVibrator;
    private final boolean mHasVibratePermission;

    // Lazily loaded helper objects.
    private AccessibilityManager mAccessibilityManager;

    // Whether or not we should be injecting the script.
    protected boolean mInjectedScriptEnabled;
    protected boolean mScriptInjected;

    private final String mAccessibilityScreenReaderUrl;

    // To support building against the JELLY_BEAN and not JELLY_BEAN_MR1 SDK we need to add this
    // constant here.
    private static final int FEEDBACK_BRAILLE = 0x00000020;

    // constants for determining script injection strategy
    private static final int ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED = -1;
    private static final int ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT = 0;
    private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1;
    private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE = "accessibility";
    private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE_2 = "accessibility2";

    // Template for JavaScript that injects a screen-reader.
    private static final String DEFAULT_ACCESSIBILITY_SCREEN_READER_URL =
            "https://ssl.gstatic.com/accessibility/javascript/android/chromeandroidvox.js";

    private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE =
            "(function() {" +
            "    var chooser = document.createElement('script');" +
            "    chooser.type = 'text/javascript';" +
            "    chooser.src = '%1s';" +
            "    document.getElementsByTagName('head')[0].appendChild(chooser);" +
            "  })();";

    // JavaScript call to turn ChromeVox on or off.
    private static final String TOGGLE_CHROME_VOX_JAVASCRIPT =
            "(function() {" +
            "    if (typeof cvox !== 'undefined') {" +
            "        cvox.ChromeVox.host.activateOrDeactivateChromeVox(%1s);" +
            "    }" +
            "  })();";

    /**
     * Returns an instance of the {@link AccessibilityInjector} based on the SDK version.
     * @param view The ContentViewCore that this AccessibilityInjector manages.
     * @return An instance of a {@link AccessibilityInjector}.
     */
    public static AccessibilityInjector newInstance(ContentViewCore view) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
            return new AccessibilityInjector(view);
        } else {
            return new JellyBeanAccessibilityInjector(view);
        }
    }

    /**
     * Creates an instance of the IceCreamSandwichAccessibilityInjector.
     * @param view The ContentViewCore that this AccessibilityInjector manages.
     */
    protected AccessibilityInjector(ContentViewCore view) {
        super(view);
        mContentViewCore = view;

        mAccessibilityScreenReaderUrl = CommandLine.getInstance().getSwitchValue(
                ContentSwitches.ACCESSIBILITY_JAVASCRIPT_URL,
                DEFAULT_ACCESSIBILITY_SCREEN_READER_URL);

        mHasVibratePermission = mContentViewCore.getContext().checkCallingOrSelfPermission(
                android.Manifest.permission.VIBRATE) == PackageManager.PERMISSION_GRANTED;
    }

    /**
     * Injects a <script> tag into the current web site that pulls in the ChromeVox script for
     * accessibility support.  Only injects if accessibility is turned on by
     * {@link AccessibilityManager#isEnabled()}, accessibility script injection is turned on, and
     * javascript is enabled on this page.
     *
     * @see AccessibilityManager#isEnabled()
     */
    public void injectAccessibilityScriptIntoPage() {
        if (!accessibilityIsAvailable()) return;

        int axsParameterValue = getAxsUrlParameterValue();
        if (axsParameterValue != ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED) {
            return;
        }

        String js = getScreenReaderInjectingJs();
        if (mContentViewCore.isDeviceAccessibilityScriptInjectionEnabled() &&
                js != null && mContentViewCore.isAlive()) {
            addOrRemoveAccessibilityApisIfNecessary();
            mContentViewCore.evaluateJavaScript(js, null);
            mInjectedScriptEnabled = true;
            mScriptInjected = true;
        }
    }

    /**
     * Handles adding or removing accessibility related Java objects ({@link TextToSpeech} and
     * {@link Vibrator}) interfaces from Javascript.  This method should be called at a time when it
     * is safe to add or remove these interfaces, specifically when the {@link ContentViewCore} is
     * first initialized or right before the {@link ContentViewCore} is about to navigate to a URL
     * or reload.
     * <p>
     * If this method is called at other times, the interfaces might not be correctly removed,
     * meaning that Javascript can still access these Java objects that may have been already
     * shut down.
     */
    public void addOrRemoveAccessibilityApisIfNecessary() {
        if (accessibilityIsAvailable()) {
            addAccessibilityApis();
        } else {
            removeAccessibilityApis();
        }
    }

    /**
     * Checks whether or not touch to explore is enabled on the system.
     */
    public boolean accessibilityIsAvailable() {
        if (!getAccessibilityManager().isEnabled() ||
                mContentViewCore.getContentSettings() == null ||
                !mContentViewCore.getContentSettings().getJavaScriptEnabled()) {
            return false;
        }

        try {
            // Check that there is actually a service running that requires injecting this script.
            List<AccessibilityServiceInfo> services =
                    getAccessibilityManager().getEnabledAccessibilityServiceList(
                            FEEDBACK_BRAILLE | AccessibilityServiceInfo.FEEDBACK_SPOKEN);
            return services.size() > 0;
        } catch (NullPointerException e) {
            // getEnabledAccessibilityServiceList() can throw an NPE due to a bad
            // AccessibilityService.
            return false;
        }
    }

    /**
     * Sets whether or not the script is enabled.  If the script is disabled, we also stop any
     * we output that is occurring. If the script has not yet been injected, injects it.
     * @param enabled Whether or not to enable the script.
     */
    public void setScriptEnabled(boolean enabled) {
        if (enabled && !mScriptInjected) injectAccessibilityScriptIntoPage();
        if (!accessibilityIsAvailable() || mInjectedScriptEnabled == enabled) return;

        mInjectedScriptEnabled = enabled;
        if (mContentViewCore.isAlive()) {
            String js = String.format(TOGGLE_CHROME_VOX_JAVASCRIPT, Boolean.toString(
                    mInjectedScriptEnabled));
            mContentViewCore.evaluateJavaScript(js, null);

            if (!mInjectedScriptEnabled) {
                // Stop any TTS/Vibration right now.
                onPageLostFocus();
            }
        }
    }

    /**
     * Notifies this handler that a page load has started, which means we should mark the
     * accessibility script as not being injected.  This way we can properly ignore incoming
     * accessibility gesture events.
     */
    @Override
    public void didStartLoading(String url) {
        mScriptInjected = false;
    }

    @Override
    public void didStopLoading(String url) {
        injectAccessibilityScriptIntoPage();
    }

    /**
     * Stop any notifications that are currently going on (e.g. Text-to-Speech).
     */
    public void onPageLostFocus() {
        if (mContentViewCore.isAlive()) {
            if (mTextToSpeech != null) mTextToSpeech.stop();
            if (mVibrator != null) mVibrator.cancel();
        }
    }

    /**
     * Initializes an {@link AccessibilityNodeInfo} with the actions and movement granularity
     * levels supported by this {@link AccessibilityInjector}.
     * <p>
     * If an action identifier is added in this method, this {@link AccessibilityInjector} should
     * also return {@code true} from {@link #supportsAccessibilityAction(int)}.
     * </p>
     *
     * @param info The info to initialize.
     * @see View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo)
     */
    public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { }

    /**
     * Returns {@code true} if this {@link AccessibilityInjector} should handle the specified
     * action.
     *
     * @param action An accessibility action identifier.
     * @return {@code true} if this {@link AccessibilityInjector} should handle the specified
     *         action.
     */
    public boolean supportsAccessibilityAction(int action) {
        return false;
    }

    /**
     * Performs the specified accessibility action.
     *
     * @param action The identifier of the action to perform.
     * @param arguments The action arguments, or {@code null} if no arguments.
     * @return {@code true} if the action was successful.
     * @see View#performAccessibilityAction(int, Bundle)
     */
    public boolean performAccessibilityAction(int action, Bundle arguments) {
        return false;
    }

    protected void addAccessibilityApis() {
        Context context = mContentViewCore.getContext();
        if (context != null) {
            // Enabled, we should try to add if we have to.
            if (mTextToSpeech == null) {
                mTextToSpeech = new TextToSpeechWrapper(mContentViewCore.getContainerView(),
                        context);
                mContentViewCore.addJavascriptInterface(mTextToSpeech,
                        ALIAS_ACCESSIBILITY_JS_INTERFACE);
            }

            if (mVibrator == null && mHasVibratePermission) {
                mVibrator = new VibratorWrapper(context);
                mContentViewCore.addJavascriptInterface(mVibrator,
                        ALIAS_ACCESSIBILITY_JS_INTERFACE_2);
            }
        }
    }

    protected void removeAccessibilityApis() {
        if (mTextToSpeech != null) {
            mContentViewCore.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE);
            mTextToSpeech.stop();
            mTextToSpeech.shutdownInternal();
            mTextToSpeech = null;
        }

        if (mVibrator != null) {
            mContentViewCore.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE_2);
            mVibrator.cancel();
            mVibrator = null;
        }
    }

    private int getAxsUrlParameterValue() {
        if (mContentViewCore.getUrl() == null) return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;

        try {
            List<NameValuePair> params = URLEncodedUtils.parse(new URI(mContentViewCore.getUrl()),
                    null);

            for (NameValuePair param : params) {
                if ("axs".equals(param.getName())) {
                    return Integer.parseInt(param.getValue());
                }
            }
        } catch (URISyntaxException ex) {
        } catch (NumberFormatException ex) {
        } catch (IllegalArgumentException ex) {
        }

        return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
    }

    private String getScreenReaderInjectingJs() {
        return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE,
                mAccessibilityScreenReaderUrl);
    }

    private AccessibilityManager getAccessibilityManager() {
        if (mAccessibilityManager == null) {
            mAccessibilityManager = (AccessibilityManager) mContentViewCore.getContext().
                    getSystemService(Context.ACCESSIBILITY_SERVICE);
        }

        return mAccessibilityManager;
    }

    /**
     * Used to protect how long JavaScript can vibrate for.  This isn't a good comprehensive
     * protection, just used to cover mistakes and protect against long vibrate durations/repeats.
     *
     * Also only exposes methods we *want* to expose, no others for the class.
     */
    private static class VibratorWrapper {
        private static final long MAX_VIBRATE_DURATION_MS = 5000;

        private Vibrator mVibrator;

        public VibratorWrapper(Context context) {
            mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
        }

        @JavascriptInterface
        @SuppressWarnings("unused")
        public boolean hasVibrator() {
            return mVibrator.hasVibrator();
        }

        @JavascriptInterface
        @SuppressWarnings("unused")
        public void vibrate(long milliseconds) {
            milliseconds = Math.min(milliseconds, MAX_VIBRATE_DURATION_MS);
            mVibrator.vibrate(milliseconds);
        }

        @JavascriptInterface
        @SuppressWarnings("unused")
        public void vibrate(long[] pattern, int repeat) {
            for (int i = 0; i < pattern.length; ++i) {
                pattern[i] = Math.min(pattern[i], MAX_VIBRATE_DURATION_MS);
            }

            repeat = -1;

            mVibrator.vibrate(pattern, repeat);
        }

        @JavascriptInterface
        @SuppressWarnings("unused")
        public void cancel() {
            mVibrator.cancel();
        }
    }

    /**
     * Used to protect the TextToSpeech class, only exposing the methods we want to expose.
     */
    private static class TextToSpeechWrapper {
        private TextToSpeech mTextToSpeech;
        private SelfBrailleClient mSelfBrailleClient;
        private View mView;

        public TextToSpeechWrapper(View view, Context context) {
            mView = view;
            mTextToSpeech = new TextToSpeech(context, null, null);
            mSelfBrailleClient = new SelfBrailleClient(context, CommandLine.getInstance().hasSwitch(
                    ContentSwitches.ACCESSIBILITY_DEBUG_BRAILLE_SERVICE));
        }

        @JavascriptInterface
        @SuppressWarnings("unused")
        public boolean isSpeaking() {
            return mTextToSpeech.isSpeaking();
        }

        @JavascriptInterface
        @SuppressWarnings("unused")
        public int speak(String text, int queueMode, String jsonParams) {
            // Try to pull the params from the JSON string.
            HashMap<String, String> params = null;
            try {
                if (jsonParams != null) {
                    params = new HashMap<String, String>();
                    JSONObject json = new JSONObject(jsonParams);

                    // Using legacy API here.
                    @SuppressWarnings("unchecked")
                    Iterator<String> keyIt = json.keys();

                    while (keyIt.hasNext()) {
                        String key = keyIt.next();
                        // Only add parameters that are raw data types.
                        if (json.optJSONObject(key) == null && json.optJSONArray(key) == null) {
                            params.put(key, json.getString(key));
                        }
                    }
                }
            } catch (JSONException e) {
                params = null;
            }

            return mTextToSpeech.speak(text, queueMode, params);
        }

        @JavascriptInterface
        @SuppressWarnings("unused")
        public int stop() {
            return mTextToSpeech.stop();
        }

        @JavascriptInterface
        @SuppressWarnings("unused")
        public void braille(String jsonString) {
            try {
                JSONObject jsonObj = new JSONObject(jsonString);

                WriteData data = WriteData.forView(mView);
                data.setText(jsonObj.getString("text"));
                data.setSelectionStart(jsonObj.getInt("startIndex"));
                data.setSelectionEnd(jsonObj.getInt("endIndex"));
                mSelfBrailleClient.write(data);
            } catch (JSONException ex) {
                Log.w(TAG, "Error parsing JS JSON object", ex);
            }
        }

        @SuppressWarnings("unused")
        protected void shutdownInternal() {
            mTextToSpeech.shutdown();
            mSelfBrailleClient.shutdown();
        }
    }
}

/* [<][>][^][v][top][bottom][index][help] */