Home | History | Annotate | Download | only in accessibility
      1 // Copyright 2012 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 package org.chromium.content.browser.accessibility;
      6 
      7 import android.accessibilityservice.AccessibilityServiceInfo;
      8 import android.content.Context;
      9 import android.content.pm.PackageManager;
     10 import android.os.Build;
     11 import android.os.Bundle;
     12 import android.os.Vibrator;
     13 import android.speech.tts.TextToSpeech;
     14 import android.util.Log;
     15 import android.view.View;
     16 import android.view.accessibility.AccessibilityManager;
     17 import android.view.accessibility.AccessibilityNodeInfo;
     18 
     19 import com.googlecode.eyesfree.braille.selfbraille.SelfBrailleClient;
     20 import com.googlecode.eyesfree.braille.selfbraille.WriteData;
     21 
     22 import org.apache.http.NameValuePair;
     23 import org.apache.http.client.utils.URLEncodedUtils;
     24 import org.chromium.base.CommandLine;
     25 import org.chromium.content.browser.ContentViewCore;
     26 import org.chromium.content.browser.JavascriptInterface;
     27 import org.chromium.content.browser.WebContentsObserverAndroid;
     28 import org.chromium.content.common.ContentSwitches;
     29 import org.json.JSONException;
     30 import org.json.JSONObject;
     31 
     32 import java.net.URI;
     33 import java.net.URISyntaxException;
     34 import java.util.HashMap;
     35 import java.util.Iterator;
     36 import java.util.List;
     37 
     38 /**
     39  * Responsible for accessibility injection and management of a {@link ContentViewCore}.
     40  */
     41 public class AccessibilityInjector extends WebContentsObserverAndroid {
     42     private static final String TAG = "AccessibilityInjector";
     43 
     44     // The ContentView this injector is responsible for managing.
     45     protected ContentViewCore mContentViewCore;
     46 
     47     // The Java objects that are exposed to JavaScript
     48     private TextToSpeechWrapper mTextToSpeech;
     49     private VibratorWrapper mVibrator;
     50     private final boolean mHasVibratePermission;
     51 
     52     // Lazily loaded helper objects.
     53     private AccessibilityManager mAccessibilityManager;
     54 
     55     // Whether or not we should be injecting the script.
     56     protected boolean mInjectedScriptEnabled;
     57     protected boolean mScriptInjected;
     58 
     59     private final String mAccessibilityScreenReaderUrl;
     60 
     61     // To support building against the JELLY_BEAN and not JELLY_BEAN_MR1 SDK we need to add this
     62     // constant here.
     63     private static final int FEEDBACK_BRAILLE = 0x00000020;
     64 
     65     // constants for determining script injection strategy
     66     private static final int ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED = -1;
     67     private static final int ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT = 0;
     68     private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1;
     69     private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE = "accessibility";
     70     private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE_2 = "accessibility2";
     71 
     72     // Template for JavaScript that injects a screen-reader.
     73     private static final String DEFAULT_ACCESSIBILITY_SCREEN_READER_URL =
     74             "https://ssl.gstatic.com/accessibility/javascript/android/chromeandroidvox.js";
     75 
     76     private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE =
     77             "(function() {" +
     78             "    var chooser = document.createElement('script');" +
     79             "    chooser.type = 'text/javascript';" +
     80             "    chooser.src = '%1s';" +
     81             "    document.getElementsByTagName('head')[0].appendChild(chooser);" +
     82             "  })();";
     83 
     84     // JavaScript call to turn ChromeVox on or off.
     85     private static final String TOGGLE_CHROME_VOX_JAVASCRIPT =
     86             "(function() {" +
     87             "    if (typeof cvox !== 'undefined') {" +
     88             "        cvox.ChromeVox.host.activateOrDeactivateChromeVox(%1s);" +
     89             "    }" +
     90             "  })();";
     91 
     92     /**
     93      * Returns an instance of the {@link AccessibilityInjector} based on the SDK version.
     94      * @param view The ContentViewCore that this AccessibilityInjector manages.
     95      * @return An instance of a {@link AccessibilityInjector}.
     96      */
     97     public static AccessibilityInjector newInstance(ContentViewCore view) {
     98         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
     99             return new AccessibilityInjector(view);
    100         } else {
    101             return new JellyBeanAccessibilityInjector(view);
    102         }
    103     }
    104 
    105     /**
    106      * Creates an instance of the IceCreamSandwichAccessibilityInjector.
    107      * @param view The ContentViewCore that this AccessibilityInjector manages.
    108      */
    109     protected AccessibilityInjector(ContentViewCore view) {
    110         super(view.getWebContents());
    111         mContentViewCore = view;
    112 
    113         mAccessibilityScreenReaderUrl = CommandLine.getInstance().getSwitchValue(
    114                 ContentSwitches.ACCESSIBILITY_JAVASCRIPT_URL,
    115                 DEFAULT_ACCESSIBILITY_SCREEN_READER_URL);
    116 
    117         mHasVibratePermission = mContentViewCore.getContext().checkCallingOrSelfPermission(
    118                 android.Manifest.permission.VIBRATE) == PackageManager.PERMISSION_GRANTED;
    119     }
    120 
    121     /**
    122      * Injects a <script> tag into the current web site that pulls in the ChromeVox script for
    123      * accessibility support.  Only injects if accessibility is turned on by
    124      * {@link AccessibilityManager#isEnabled()}, accessibility script injection is turned on, and
    125      * javascript is enabled on this page.
    126      *
    127      * @see AccessibilityManager#isEnabled()
    128      */
    129     public void injectAccessibilityScriptIntoPage() {
    130         if (!accessibilityIsAvailable()) return;
    131 
    132         int axsParameterValue = getAxsUrlParameterValue();
    133         if (axsParameterValue != ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED) {
    134             return;
    135         }
    136 
    137         String js = getScreenReaderInjectingJs();
    138         if (mContentViewCore.isDeviceAccessibilityScriptInjectionEnabled() &&
    139                 js != null && mContentViewCore.isAlive()) {
    140             addOrRemoveAccessibilityApisIfNecessary();
    141             mContentViewCore.evaluateJavaScript(js, null);
    142             mInjectedScriptEnabled = true;
    143             mScriptInjected = true;
    144         }
    145     }
    146 
    147     /**
    148      * Handles adding or removing accessibility related Java objects ({@link TextToSpeech} and
    149      * {@link Vibrator}) interfaces from Javascript.  This method should be called at a time when it
    150      * is safe to add or remove these interfaces, specifically when the {@link ContentViewCore} is
    151      * first initialized or right before the {@link ContentViewCore} is about to navigate to a URL
    152      * or reload.
    153      * <p>
    154      * If this method is called at other times, the interfaces might not be correctly removed,
    155      * meaning that Javascript can still access these Java objects that may have been already
    156      * shut down.
    157      */
    158     public void addOrRemoveAccessibilityApisIfNecessary() {
    159         if (accessibilityIsAvailable()) {
    160             addAccessibilityApis();
    161         } else {
    162             removeAccessibilityApis();
    163         }
    164     }
    165 
    166     /**
    167      * Checks whether or not touch to explore is enabled on the system.
    168      */
    169     public boolean accessibilityIsAvailable() {
    170         if (!getAccessibilityManager().isEnabled() ||
    171                 mContentViewCore.getContentSettings() == null ||
    172                 !mContentViewCore.getContentSettings().getJavaScriptEnabled()) {
    173             return false;
    174         }
    175 
    176         try {
    177             // Check that there is actually a service running that requires injecting this script.
    178             List<AccessibilityServiceInfo> services =
    179                     getAccessibilityManager().getEnabledAccessibilityServiceList(
    180                             FEEDBACK_BRAILLE | AccessibilityServiceInfo.FEEDBACK_SPOKEN);
    181             return services.size() > 0;
    182         } catch (NullPointerException e) {
    183             // getEnabledAccessibilityServiceList() can throw an NPE due to a bad
    184             // AccessibilityService.
    185             return false;
    186         }
    187     }
    188 
    189     /**
    190      * Sets whether or not the script is enabled.  If the script is disabled, we also stop any
    191      * we output that is occurring. If the script has not yet been injected, injects it.
    192      * @param enabled Whether or not to enable the script.
    193      */
    194     public void setScriptEnabled(boolean enabled) {
    195         if (enabled && !mScriptInjected) injectAccessibilityScriptIntoPage();
    196         if (!accessibilityIsAvailable() || mInjectedScriptEnabled == enabled) return;
    197 
    198         mInjectedScriptEnabled = enabled;
    199         if (mContentViewCore.isAlive()) {
    200             String js = String.format(TOGGLE_CHROME_VOX_JAVASCRIPT, Boolean.toString(
    201                     mInjectedScriptEnabled));
    202             mContentViewCore.evaluateJavaScript(js, null);
    203 
    204             if (!mInjectedScriptEnabled) {
    205                 // Stop any TTS/Vibration right now.
    206                 onPageLostFocus();
    207             }
    208         }
    209     }
    210 
    211     /**
    212      * Notifies this handler that a page load has started, which means we should mark the
    213      * accessibility script as not being injected.  This way we can properly ignore incoming
    214      * accessibility gesture events.
    215      */
    216     @Override
    217     public void didStartLoading(String url) {
    218         mScriptInjected = false;
    219     }
    220 
    221     @Override
    222     public void didStopLoading(String url) {
    223         injectAccessibilityScriptIntoPage();
    224     }
    225 
    226     /**
    227      * Stop any notifications that are currently going on (e.g. Text-to-Speech).
    228      */
    229     public void onPageLostFocus() {
    230         if (mContentViewCore.isAlive()) {
    231             if (mTextToSpeech != null) mTextToSpeech.stop();
    232             if (mVibrator != null) mVibrator.cancel();
    233         }
    234     }
    235 
    236     /**
    237      * Initializes an {@link AccessibilityNodeInfo} with the actions and movement granularity
    238      * levels supported by this {@link AccessibilityInjector}.
    239      * <p>
    240      * If an action identifier is added in this method, this {@link AccessibilityInjector} should
    241      * also return {@code true} from {@link #supportsAccessibilityAction(int)}.
    242      * </p>
    243      *
    244      * @param info The info to initialize.
    245      * @see View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo)
    246      */
    247     public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { }
    248 
    249     /**
    250      * Returns {@code true} if this {@link AccessibilityInjector} should handle the specified
    251      * action.
    252      *
    253      * @param action An accessibility action identifier.
    254      * @return {@code true} if this {@link AccessibilityInjector} should handle the specified
    255      *         action.
    256      */
    257     public boolean supportsAccessibilityAction(int action) {
    258         return false;
    259     }
    260 
    261     /**
    262      * Performs the specified accessibility action.
    263      *
    264      * @param action The identifier of the action to perform.
    265      * @param arguments The action arguments, or {@code null} if no arguments.
    266      * @return {@code true} if the action was successful.
    267      * @see View#performAccessibilityAction(int, Bundle)
    268      */
    269     public boolean performAccessibilityAction(int action, Bundle arguments) {
    270         return false;
    271     }
    272 
    273     protected void addAccessibilityApis() {
    274         Context context = mContentViewCore.getContext();
    275         if (context != null) {
    276             // Enabled, we should try to add if we have to.
    277             if (mTextToSpeech == null) {
    278                 mTextToSpeech = new TextToSpeechWrapper(mContentViewCore.getContainerView(),
    279                         context);
    280                 mContentViewCore.addJavascriptInterface(mTextToSpeech,
    281                         ALIAS_ACCESSIBILITY_JS_INTERFACE);
    282             }
    283 
    284             if (mVibrator == null && mHasVibratePermission) {
    285                 mVibrator = new VibratorWrapper(context);
    286                 mContentViewCore.addJavascriptInterface(mVibrator,
    287                         ALIAS_ACCESSIBILITY_JS_INTERFACE_2);
    288             }
    289         }
    290     }
    291 
    292     protected void removeAccessibilityApis() {
    293         if (mTextToSpeech != null) {
    294             mContentViewCore.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE);
    295             mTextToSpeech.stop();
    296             mTextToSpeech.shutdownInternal();
    297             mTextToSpeech = null;
    298         }
    299 
    300         if (mVibrator != null) {
    301             mContentViewCore.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE_2);
    302             mVibrator.cancel();
    303             mVibrator = null;
    304         }
    305     }
    306 
    307     private int getAxsUrlParameterValue() {
    308         if (mContentViewCore.getWebContents().getUrl() == null) {
    309             return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
    310         }
    311 
    312         try {
    313             List<NameValuePair> params = URLEncodedUtils.parse(
    314                     new URI(mContentViewCore.getWebContents().getUrl()), null);
    315 
    316             for (NameValuePair param : params) {
    317                 if ("axs".equals(param.getName())) {
    318                     return Integer.parseInt(param.getValue());
    319                 }
    320             }
    321         } catch (URISyntaxException ex) {
    322             // Intentional no-op.
    323         } catch (NumberFormatException ex) {
    324             // Intentional no-op.
    325         } catch (IllegalArgumentException ex) {
    326             // Intentional no-op.
    327         }
    328 
    329         return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
    330     }
    331 
    332     private String getScreenReaderInjectingJs() {
    333         return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE,
    334                 mAccessibilityScreenReaderUrl);
    335     }
    336 
    337     private AccessibilityManager getAccessibilityManager() {
    338         if (mAccessibilityManager == null) {
    339             mAccessibilityManager = (AccessibilityManager) mContentViewCore.getContext().
    340                     getSystemService(Context.ACCESSIBILITY_SERVICE);
    341         }
    342 
    343         return mAccessibilityManager;
    344     }
    345 
    346     /**
    347      * Used to protect how long JavaScript can vibrate for.  This isn't a good comprehensive
    348      * protection, just used to cover mistakes and protect against long vibrate durations/repeats.
    349      *
    350      * Also only exposes methods we *want* to expose, no others for the class.
    351      */
    352     private static class VibratorWrapper {
    353         private static final long MAX_VIBRATE_DURATION_MS = 5000;
    354 
    355         private final Vibrator mVibrator;
    356 
    357         public VibratorWrapper(Context context) {
    358             mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
    359         }
    360 
    361         @JavascriptInterface
    362         @SuppressWarnings("unused")
    363         public boolean hasVibrator() {
    364             return mVibrator.hasVibrator();
    365         }
    366 
    367         @JavascriptInterface
    368         @SuppressWarnings("unused")
    369         public void vibrate(long milliseconds) {
    370             milliseconds = Math.min(milliseconds, MAX_VIBRATE_DURATION_MS);
    371             mVibrator.vibrate(milliseconds);
    372         }
    373 
    374         @JavascriptInterface
    375         @SuppressWarnings("unused")
    376         public void vibrate(long[] pattern, int repeat) {
    377             for (int i = 0; i < pattern.length; ++i) {
    378                 pattern[i] = Math.min(pattern[i], MAX_VIBRATE_DURATION_MS);
    379             }
    380 
    381             repeat = -1;
    382 
    383             mVibrator.vibrate(pattern, repeat);
    384         }
    385 
    386         @JavascriptInterface
    387         @SuppressWarnings("unused")
    388         public void cancel() {
    389             mVibrator.cancel();
    390         }
    391     }
    392 
    393     /**
    394      * Used to protect the TextToSpeech class, only exposing the methods we want to expose.
    395      */
    396     private static class TextToSpeechWrapper {
    397         private final TextToSpeech mTextToSpeech;
    398         private final SelfBrailleClient mSelfBrailleClient;
    399         private final View mView;
    400 
    401         public TextToSpeechWrapper(View view, Context context) {
    402             mView = view;
    403             mTextToSpeech = new TextToSpeech(context, null, null);
    404             mSelfBrailleClient = new SelfBrailleClient(context, CommandLine.getInstance().hasSwitch(
    405                     ContentSwitches.ACCESSIBILITY_DEBUG_BRAILLE_SERVICE));
    406         }
    407 
    408         @JavascriptInterface
    409         @SuppressWarnings("unused")
    410         public boolean isSpeaking() {
    411             return mTextToSpeech.isSpeaking();
    412         }
    413 
    414         @JavascriptInterface
    415         @SuppressWarnings("unused")
    416         public int speak(String text, int queueMode, String jsonParams) {
    417             // Try to pull the params from the JSON string.
    418             HashMap<String, String> params = null;
    419             try {
    420                 if (jsonParams != null) {
    421                     params = new HashMap<String, String>();
    422                     JSONObject json = new JSONObject(jsonParams);
    423 
    424                     // Using legacy API here.
    425                     @SuppressWarnings("unchecked")
    426                     Iterator<String> keyIt = json.keys();
    427 
    428                     while (keyIt.hasNext()) {
    429                         String key = keyIt.next();
    430                         // Only add parameters that are raw data types.
    431                         if (json.optJSONObject(key) == null && json.optJSONArray(key) == null) {
    432                             params.put(key, json.getString(key));
    433                         }
    434                     }
    435                 }
    436             } catch (JSONException e) {
    437                 params = null;
    438             }
    439 
    440             return mTextToSpeech.speak(text, queueMode, params);
    441         }
    442 
    443         @JavascriptInterface
    444         @SuppressWarnings("unused")
    445         public int stop() {
    446             return mTextToSpeech.stop();
    447         }
    448 
    449         @JavascriptInterface
    450         @SuppressWarnings("unused")
    451         public void braille(String jsonString) {
    452             try {
    453                 JSONObject jsonObj = new JSONObject(jsonString);
    454 
    455                 WriteData data = WriteData.forView(mView);
    456                 data.setText(jsonObj.getString("text"));
    457                 data.setSelectionStart(jsonObj.getInt("startIndex"));
    458                 data.setSelectionEnd(jsonObj.getInt("endIndex"));
    459                 mSelfBrailleClient.write(data);
    460             } catch (JSONException ex) {
    461                 Log.w(TAG, "Error parsing JS JSON object", ex);
    462             }
    463         }
    464 
    465         @SuppressWarnings("unused")
    466         protected void shutdownInternal() {
    467             mTextToSpeech.shutdown();
    468             mSelfBrailleClient.shutdown();
    469         }
    470     }
    471 }
    472