| /* |
| * Copyright (C) 2012 The Android Open Source Project |
| * |
| * 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 android.webkit; |
| |
| import android.content.Context; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.os.SystemClock; |
| import android.provider.Settings; |
| import android.speech.tts.TextToSpeech; |
| import android.speech.tts.TextToSpeech.Engine; |
| import android.speech.tts.TextToSpeech.OnInitListener; |
| import android.speech.tts.UtteranceProgressListener; |
| import android.util.Log; |
| import android.view.KeyEvent; |
| import android.view.View; |
| import android.view.accessibility.AccessibilityManager; |
| import android.view.accessibility.AccessibilityNodeInfo; |
| import android.webkit.WebViewCore.EventHub; |
| |
| import org.apache.http.NameValuePair; |
| import org.apache.http.client.utils.URLEncodedUtils; |
| 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; |
| import java.util.concurrent.atomic.AtomicInteger; |
| |
| /** |
| * Handles injecting accessibility JavaScript and related JavaScript -> Java |
| * APIs. |
| */ |
| class AccessibilityInjector { |
| private static final String TAG = AccessibilityInjector.class.getSimpleName(); |
| |
| private static boolean DEBUG = false; |
| |
| // The WebViewClassic this injector is responsible for managing. |
| private final WebViewClassic mWebViewClassic; |
| |
| // Cached reference to mWebViewClassic.getContext(), for convenience. |
| private final Context mContext; |
| |
| // Cached reference to mWebViewClassic.getWebView(), for convenience. |
| private final WebView mWebView; |
| |
| // The Java objects that are exposed to JavaScript. |
| private TextToSpeechWrapper mTextToSpeech; |
| private CallbackHandler mCallback; |
| |
| // Lazily loaded helper objects. |
| private AccessibilityManager mAccessibilityManager; |
| private AccessibilityInjectorFallback mAccessibilityInjectorFallback; |
| private JSONObject mAccessibilityJSONObject; |
| |
| // Whether the accessibility script has been injected into the current page. |
| private boolean mAccessibilityScriptInjected; |
| |
| // 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; |
| @SuppressWarnings("unused") |
| private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1; |
| |
| // Alias for TTS API exposed to JavaScript. |
| private static final String ALIAS_TTS_JS_INTERFACE = "accessibility"; |
| |
| // Alias for traversal callback exposed to JavaScript. |
| private static final String ALIAS_TRAVERSAL_JS_INTERFACE = "accessibilityTraversal"; |
| |
| // Template for JavaScript that injects a screen-reader. |
| private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE = |
| "javascript:(function() {" + |
| " var chooser = document.createElement('script');" + |
| " chooser.type = 'text/javascript';" + |
| " chooser.src = '%1s';" + |
| " document.getElementsByTagName('head')[0].appendChild(chooser);" + |
| " })();"; |
| |
| // Template for JavaScript that performs AndroidVox actions. |
| private static final String ACCESSIBILITY_ANDROIDVOX_TEMPLATE = |
| "(function() {" + |
| " if ((typeof(cvox) != 'undefined')" + |
| " && (cvox != null)" + |
| " && (typeof(cvox.ChromeVox) != 'undefined')" + |
| " && (cvox.ChromeVox != null)" + |
| " && (typeof(cvox.AndroidVox) != 'undefined')" + |
| " && (cvox.AndroidVox != null)" + |
| " && cvox.ChromeVox.isActive) {" + |
| " return cvox.AndroidVox.performAction('%1s');" + |
| " } else {" + |
| " return false;" + |
| " }" + |
| "})()"; |
| |
| // JS code used to shut down an active AndroidVox instance. |
| private static final String TOGGLE_CVOX_TEMPLATE = |
| "javascript:(function() {" + |
| " if ((typeof(cvox) != 'undefined')" + |
| " && (cvox != null)" + |
| " && (typeof(cvox.ChromeVox) != 'undefined')" + |
| " && (cvox.ChromeVox != null)" + |
| " && (typeof(cvox.ChromeVox.host) != 'undefined')" + |
| " && (cvox.ChromeVox.host != null)) {" + |
| " cvox.ChromeVox.host.activateOrDeactivateChromeVox(%b);" + |
| " }" + |
| "})();"; |
| |
| /** |
| * Creates an instance of the AccessibilityInjector based on |
| * {@code webViewClassic}. |
| * |
| * @param webViewClassic The WebViewClassic that this AccessibilityInjector |
| * manages. |
| */ |
| public AccessibilityInjector(WebViewClassic webViewClassic) { |
| mWebViewClassic = webViewClassic; |
| mWebView = webViewClassic.getWebView(); |
| mContext = webViewClassic.getContext(); |
| mAccessibilityManager = AccessibilityManager.getInstance(mContext); |
| } |
| |
| /** |
| * If JavaScript is enabled, pauses or resumes AndroidVox. |
| * |
| * @param enabled Whether feedback should be enabled. |
| */ |
| public void toggleAccessibilityFeedback(boolean enabled) { |
| if (!isAccessibilityEnabled() || !isJavaScriptEnabled()) { |
| return; |
| } |
| |
| toggleAndroidVox(enabled); |
| |
| if (!enabled && (mTextToSpeech != null)) { |
| mTextToSpeech.stop(); |
| } |
| } |
| |
| /** |
| * Attempts to load scripting interfaces for accessibility. |
| * <p> |
| * This should only be called before a page loads. |
| */ |
| public void addAccessibilityApisIfNecessary() { |
| if (!isAccessibilityEnabled() || !isJavaScriptEnabled()) { |
| return; |
| } |
| |
| addTtsApis(); |
| addCallbackApis(); |
| } |
| |
| /** |
| * Attempts to unload scripting interfaces for accessibility. |
| * <p> |
| * This should only be called before a page loads. |
| */ |
| private void removeAccessibilityApisIfNecessary() { |
| removeTtsApis(); |
| removeCallbackApis(); |
| } |
| |
| /** |
| * Destroys this accessibility injector. |
| */ |
| public void destroy() { |
| if (mTextToSpeech != null) { |
| mTextToSpeech.shutdown(); |
| mTextToSpeech = null; |
| } |
| |
| if (mCallback != null) { |
| mCallback = null; |
| } |
| } |
| |
| private void toggleAndroidVox(boolean state) { |
| if (!mAccessibilityScriptInjected) { |
| return; |
| } |
| |
| final String code = String.format(TOGGLE_CVOX_TEMPLATE, state); |
| mWebView.loadUrl(code); |
| } |
| |
| /** |
| * 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) { |
| info.setMovementGranularities(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER |
| | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD |
| | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE |
| | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH |
| | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE); |
| info.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); |
| info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); |
| info.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT); |
| info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT); |
| info.addAction(AccessibilityNodeInfo.ACTION_CLICK); |
| info.setClickable(true); |
| } |
| |
| /** |
| * 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) { |
| switch (action) { |
| case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: |
| case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: |
| case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT: |
| case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT: |
| case AccessibilityNodeInfo.ACTION_CLICK: |
| return true; |
| default: |
| 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) { |
| if (!isAccessibilityEnabled()) { |
| mAccessibilityScriptInjected = false; |
| toggleFallbackAccessibilityInjector(false); |
| return false; |
| } |
| |
| if (mAccessibilityScriptInjected) { |
| return sendActionToAndroidVox(action, arguments); |
| } |
| |
| if (mAccessibilityInjectorFallback != null) { |
| return mAccessibilityInjectorFallback.performAccessibilityAction(action, arguments); |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Attempts to handle key events when accessibility is turned on. |
| * |
| * @param event The key event to handle. |
| * @return {@code true} if the event was handled. |
| */ |
| public boolean handleKeyEventIfNecessary(KeyEvent event) { |
| if (!isAccessibilityEnabled()) { |
| mAccessibilityScriptInjected = false; |
| toggleFallbackAccessibilityInjector(false); |
| return false; |
| } |
| |
| if (mAccessibilityScriptInjected) { |
| // if an accessibility script is injected we delegate to it the key |
| // handling. this script is a screen reader which is a fully fledged |
| // solution for blind users to navigate in and interact with web |
| // pages. |
| if (event.getAction() == KeyEvent.ACTION_UP) { |
| mWebViewClassic.sendBatchableInputMessage(EventHub.KEY_UP, 0, 0, event); |
| } else if (event.getAction() == KeyEvent.ACTION_DOWN) { |
| mWebViewClassic.sendBatchableInputMessage(EventHub.KEY_DOWN, 0, 0, event); |
| } else { |
| return false; |
| } |
| |
| return true; |
| } |
| |
| if (mAccessibilityInjectorFallback != null) { |
| // if an accessibility injector is present (no JavaScript enabled or |
| // the site opts out injecting our JavaScript screen reader) we let |
| // it decide whether to act on and consume the event. |
| return mAccessibilityInjectorFallback.onKeyEvent(event); |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Attempts to handle selection change events when accessibility is using a |
| * non-JavaScript method. |
| * <p> |
| * This must not be called from the main thread. |
| * |
| * @param selection The selection string. |
| * @param token The selection request token. |
| */ |
| public void onSelectionStringChangedWebCoreThread(String selection, int token) { |
| if (mAccessibilityInjectorFallback != null) { |
| mAccessibilityInjectorFallback.onSelectionStringChangedWebCoreThread(selection, token); |
| } |
| } |
| |
| /** |
| * Prepares for injecting accessibility scripts into a new page. |
| * |
| * @param url The URL that will be loaded. |
| */ |
| public void onPageStarted(String url) { |
| mAccessibilityScriptInjected = false; |
| if (DEBUG) { |
| Log.w(TAG, "[" + mWebView.hashCode() + "] Started loading new page"); |
| } |
| addAccessibilityApisIfNecessary(); |
| } |
| |
| /** |
| * Attempts to inject the accessibility script using a {@code <script>} tag. |
| * <p> |
| * This should be called after a page has finished loading. |
| * </p> |
| * |
| * @param url The URL that just finished loading. |
| */ |
| public void onPageFinished(String url) { |
| if (!isAccessibilityEnabled()) { |
| toggleFallbackAccessibilityInjector(false); |
| return; |
| } |
| |
| toggleFallbackAccessibilityInjector(true); |
| |
| if (shouldInjectJavaScript(url)) { |
| // If we're supposed to use the JS screen reader, request a |
| // callback to confirm that CallbackHandler is working. |
| if (DEBUG) { |
| Log.d(TAG, "[" + mWebView.hashCode() + "] Request callback "); |
| } |
| |
| mCallback.requestCallback(mWebView, mInjectScriptRunnable); |
| } |
| } |
| |
| /** |
| * Runnable used to inject the JavaScript-based screen reader if the |
| * {@link CallbackHandler} API was successfully exposed to JavaScript. |
| */ |
| private Runnable mInjectScriptRunnable = new Runnable() { |
| @Override |
| public void run() { |
| if (DEBUG) { |
| Log.d(TAG, "[" + mWebView.hashCode() + "] Received callback"); |
| } |
| |
| injectJavaScript(); |
| } |
| }; |
| |
| /** |
| * Called by {@link #mInjectScriptRunnable} to inject the JavaScript-based |
| * screen reader after confirming that the {@link CallbackHandler} API is |
| * functional. |
| */ |
| private void injectJavaScript() { |
| toggleFallbackAccessibilityInjector(false); |
| |
| if (!mAccessibilityScriptInjected) { |
| mAccessibilityScriptInjected = true; |
| final String injectionUrl = getScreenReaderInjectionUrl(); |
| mWebView.loadUrl(injectionUrl); |
| if (DEBUG) { |
| Log.d(TAG, "[" + mWebView.hashCode() + "] Loading screen reader into WebView"); |
| } |
| } else { |
| if (DEBUG) { |
| Log.w(TAG, "[" + mWebView.hashCode() + "] Attempted to inject screen reader twice"); |
| } |
| } |
| } |
| |
| /** |
| * Adjusts the accessibility injection state to reflect changes in the |
| * JavaScript enabled state. |
| * |
| * @param enabled Whether JavaScript is enabled. |
| */ |
| public void updateJavaScriptEnabled(boolean enabled) { |
| if (enabled) { |
| addAccessibilityApisIfNecessary(); |
| } else { |
| removeAccessibilityApisIfNecessary(); |
| } |
| |
| // We have to reload the page after adding or removing APIs. |
| mWebView.reload(); |
| } |
| |
| /** |
| * Toggles the non-JavaScript method for handling accessibility. |
| * |
| * @param enabled {@code true} to enable the non-JavaScript method, or |
| * {@code false} to disable it. |
| */ |
| private void toggleFallbackAccessibilityInjector(boolean enabled) { |
| if (enabled && (mAccessibilityInjectorFallback == null)) { |
| mAccessibilityInjectorFallback = new AccessibilityInjectorFallback(mWebViewClassic); |
| } else { |
| mAccessibilityInjectorFallback = null; |
| } |
| } |
| |
| /** |
| * Determines whether it's okay to inject JavaScript into a given URL. |
| * |
| * @param url The URL to check. |
| * @return {@code true} if JavaScript should be injected, {@code false} if a |
| * non-JavaScript method should be used. |
| */ |
| private boolean shouldInjectJavaScript(String url) { |
| // Respect the WebView's JavaScript setting. |
| if (!isJavaScriptEnabled()) { |
| return false; |
| } |
| |
| // Allow the page to opt out of Accessibility script injection. |
| if (getAxsUrlParameterValue(url) == ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT) { |
| return false; |
| } |
| |
| // The user must explicitly enable Accessibility script injection. |
| if (!isScriptInjectionEnabled()) { |
| return false; |
| } |
| |
| return true; |
| } |
| |
| /** |
| * @return {@code true} if the user has explicitly enabled Accessibility |
| * script injection. |
| */ |
| private boolean isScriptInjectionEnabled() { |
| final int injectionSetting = Settings.Secure.getInt( |
| mContext.getContentResolver(), Settings.Secure.ACCESSIBILITY_SCRIPT_INJECTION, 0); |
| return (injectionSetting == 1); |
| } |
| |
| /** |
| * Attempts to initialize and add interfaces for TTS, if that hasn't already |
| * been done. |
| */ |
| private void addTtsApis() { |
| if (mTextToSpeech == null) { |
| mTextToSpeech = new TextToSpeechWrapper(mContext); |
| } |
| |
| mWebView.addJavascriptInterface(mTextToSpeech, ALIAS_TTS_JS_INTERFACE); |
| } |
| |
| /** |
| * Attempts to shutdown and remove interfaces for TTS, if that hasn't |
| * already been done. |
| */ |
| private void removeTtsApis() { |
| if (mTextToSpeech != null) { |
| mTextToSpeech.stop(); |
| mTextToSpeech.shutdown(); |
| mTextToSpeech = null; |
| } |
| |
| mWebView.removeJavascriptInterface(ALIAS_TTS_JS_INTERFACE); |
| } |
| |
| private void addCallbackApis() { |
| if (mCallback == null) { |
| mCallback = new CallbackHandler(ALIAS_TRAVERSAL_JS_INTERFACE); |
| } |
| |
| mWebView.addJavascriptInterface(mCallback, ALIAS_TRAVERSAL_JS_INTERFACE); |
| } |
| |
| private void removeCallbackApis() { |
| if (mCallback != null) { |
| mCallback = null; |
| } |
| |
| mWebView.removeJavascriptInterface(ALIAS_TRAVERSAL_JS_INTERFACE); |
| } |
| |
| /** |
| * Returns the script injection preference requested by the URL, or |
| * {@link #ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED} if the page has no |
| * preference. |
| * |
| * @param url The URL to check. |
| * @return A script injection preference. |
| */ |
| private int getAxsUrlParameterValue(String url) { |
| if (url == null) { |
| return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED; |
| } |
| |
| try { |
| final List<NameValuePair> params = URLEncodedUtils.parse(new URI(url), null); |
| |
| for (NameValuePair param : params) { |
| if ("axs".equals(param.getName())) { |
| return verifyInjectionValue(param.getValue()); |
| } |
| } |
| } catch (URISyntaxException e) { |
| // Do nothing. |
| } catch (IllegalArgumentException e) { |
| // Catch badly-formed URLs. |
| } |
| |
| return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED; |
| } |
| |
| private int verifyInjectionValue(String value) { |
| try { |
| final int parsed = Integer.parseInt(value); |
| |
| switch (parsed) { |
| case ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT: |
| return ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT; |
| case ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED: |
| return ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED; |
| } |
| } catch (NumberFormatException e) { |
| // Do nothing. |
| } |
| |
| return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED; |
| } |
| |
| /** |
| * @return The URL for injecting the screen reader. |
| */ |
| private String getScreenReaderInjectionUrl() { |
| final String screenReaderUrl = Settings.Secure.getString( |
| mContext.getContentResolver(), Settings.Secure.ACCESSIBILITY_SCREEN_READER_URL); |
| return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE, screenReaderUrl); |
| } |
| |
| /** |
| * @return {@code true} if JavaScript is enabled in the {@link WebView} |
| * settings. |
| */ |
| private boolean isJavaScriptEnabled() { |
| final WebSettings settings = mWebView.getSettings(); |
| if (settings == null) { |
| return false; |
| } |
| |
| return settings.getJavaScriptEnabled(); |
| } |
| |
| /** |
| * @return {@code true} if accessibility is enabled. |
| */ |
| private boolean isAccessibilityEnabled() { |
| return mAccessibilityManager.isEnabled(); |
| } |
| |
| /** |
| * Packs an accessibility action into a JSON object and sends it to AndroidVox. |
| * |
| * @param action The action identifier. |
| * @param arguments The action arguments, if applicable. |
| * @return The result of the action. |
| */ |
| private boolean sendActionToAndroidVox(int action, Bundle arguments) { |
| if (mAccessibilityJSONObject == null) { |
| mAccessibilityJSONObject = new JSONObject(); |
| } else { |
| // Remove all keys from the object. |
| final Iterator<?> keys = mAccessibilityJSONObject.keys(); |
| while (keys.hasNext()) { |
| keys.next(); |
| keys.remove(); |
| } |
| } |
| |
| try { |
| mAccessibilityJSONObject.accumulate("action", action); |
| |
| switch (action) { |
| case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: |
| case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: |
| if (arguments != null) { |
| final int granularity = arguments.getInt( |
| AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); |
| mAccessibilityJSONObject.accumulate("granularity", granularity); |
| } |
| break; |
| case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT: |
| case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT: |
| if (arguments != null) { |
| final String element = arguments.getString( |
| AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING); |
| mAccessibilityJSONObject.accumulate("element", element); |
| } |
| break; |
| } |
| } catch (JSONException e) { |
| return false; |
| } |
| |
| final String jsonString = mAccessibilityJSONObject.toString(); |
| final String jsCode = String.format(ACCESSIBILITY_ANDROIDVOX_TEMPLATE, jsonString); |
| return mCallback.performAction(mWebView, jsCode); |
| } |
| |
| /** |
| * Used to protect the TextToSpeech class, only exposing the methods we want to expose. |
| */ |
| private static class TextToSpeechWrapper { |
| private static final String WRAP_TAG = TextToSpeechWrapper.class.getSimpleName(); |
| |
| /** Lock used to control access to the TextToSpeech object. */ |
| private final Object mTtsLock = new Object(); |
| |
| private final HashMap<String, String> mTtsParams; |
| private final TextToSpeech mTextToSpeech; |
| |
| /** |
| * Whether this wrapper is ready to speak. If this is {@code true} then |
| * {@link #mShutdown} is guaranteed to be {@code false}. |
| */ |
| private volatile boolean mReady; |
| |
| /** |
| * Whether this wrapper was shut down. If this is {@code true} then |
| * {@link #mReady} is guaranteed to be {@code false}. |
| */ |
| private volatile boolean mShutdown; |
| |
| public TextToSpeechWrapper(Context context) { |
| if (DEBUG) { |
| Log.d(WRAP_TAG, "[" + hashCode() + "] Initializing text-to-speech on thread " |
| + Thread.currentThread().getId() + "..."); |
| } |
| |
| final String pkgName = context.getPackageName(); |
| |
| mReady = false; |
| mShutdown = false; |
| |
| mTtsParams = new HashMap<String, String>(); |
| mTtsParams.put(Engine.KEY_PARAM_UTTERANCE_ID, WRAP_TAG); |
| |
| mTextToSpeech = new TextToSpeech( |
| context, mInitListener, null, pkgName + ".**webview**", true); |
| mTextToSpeech.setOnUtteranceProgressListener(mErrorListener); |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public boolean isSpeaking() { |
| synchronized (mTtsLock) { |
| if (!mReady) { |
| return false; |
| } |
| |
| return mTextToSpeech.isSpeaking(); |
| } |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public int speak(String text, int queueMode, HashMap<String, String> params) { |
| synchronized (mTtsLock) { |
| if (!mReady) { |
| if (DEBUG) { |
| Log.w(WRAP_TAG, "[" + hashCode() + "] Attempted to speak before TTS init"); |
| } |
| return TextToSpeech.ERROR; |
| } else { |
| if (DEBUG) { |
| Log.i(WRAP_TAG, "[" + hashCode() + "] Speak called from JS binder"); |
| } |
| } |
| |
| return mTextToSpeech.speak(text, queueMode, params); |
| } |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public int stop() { |
| synchronized (mTtsLock) { |
| if (!mReady) { |
| if (DEBUG) { |
| Log.w(WRAP_TAG, "[" + hashCode() + "] Attempted to stop before initialize"); |
| } |
| return TextToSpeech.ERROR; |
| } else { |
| if (DEBUG) { |
| Log.i(WRAP_TAG, "[" + hashCode() + "] Stop called from JS binder"); |
| } |
| } |
| |
| return mTextToSpeech.stop(); |
| } |
| } |
| |
| @SuppressWarnings("unused") |
| protected void shutdown() { |
| synchronized (mTtsLock) { |
| if (!mReady) { |
| if (DEBUG) { |
| Log.w(WRAP_TAG, "[" + hashCode() + "] Called shutdown before initialize"); |
| } |
| } else { |
| if (DEBUG) { |
| Log.i(WRAP_TAG, "[" + hashCode() + "] Shutting down text-to-speech from " |
| + "thread " + Thread.currentThread().getId() + "..."); |
| } |
| } |
| mShutdown = true; |
| mReady = false; |
| mTextToSpeech.shutdown(); |
| } |
| } |
| |
| private final OnInitListener mInitListener = new OnInitListener() { |
| @Override |
| public void onInit(int status) { |
| synchronized (mTtsLock) { |
| if (!mShutdown && (status == TextToSpeech.SUCCESS)) { |
| if (DEBUG) { |
| Log.d(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode() |
| + "] Initialized successfully"); |
| } |
| mReady = true; |
| } else { |
| if (DEBUG) { |
| Log.w(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode() |
| + "] Failed to initialize"); |
| } |
| mReady = false; |
| } |
| } |
| } |
| }; |
| |
| private final UtteranceProgressListener mErrorListener = new UtteranceProgressListener() { |
| @Override |
| public void onStart(String utteranceId) { |
| // Do nothing. |
| } |
| |
| @Override |
| public void onError(String utteranceId) { |
| if (DEBUG) { |
| Log.w(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode() |
| + "] Failed to speak utterance"); |
| } |
| } |
| |
| @Override |
| public void onDone(String utteranceId) { |
| // Do nothing. |
| } |
| }; |
| } |
| |
| /** |
| * Exposes result interface to JavaScript. |
| */ |
| private static class CallbackHandler { |
| private static final String JAVASCRIPT_ACTION_TEMPLATE = |
| "javascript:(function() { %s.onResult(%d, %s); })();"; |
| |
| // Time in milliseconds to wait for a result before failing. |
| private static final long RESULT_TIMEOUT = 5000; |
| |
| private final AtomicInteger mResultIdCounter = new AtomicInteger(); |
| private final Object mResultLock = new Object(); |
| private final String mInterfaceName; |
| private final Handler mMainHandler; |
| |
| private Runnable mCallbackRunnable; |
| |
| private boolean mResult = false; |
| private int mResultId = -1; |
| |
| private CallbackHandler(String interfaceName) { |
| mInterfaceName = interfaceName; |
| mMainHandler = new Handler(); |
| } |
| |
| /** |
| * Performs an action and attempts to wait for a result. |
| * |
| * @param webView The WebView to perform the action on. |
| * @param code JavaScript code that evaluates to a result. |
| * @return The result of the action, or false if it timed out. |
| */ |
| private boolean performAction(WebView webView, String code) { |
| final int resultId = mResultIdCounter.getAndIncrement(); |
| final String url = String.format( |
| JAVASCRIPT_ACTION_TEMPLATE, mInterfaceName, resultId, code); |
| webView.loadUrl(url); |
| |
| return getResultAndClear(resultId); |
| } |
| |
| /** |
| * Gets the result of a request to perform an accessibility action. |
| * |
| * @param resultId The result id to match the result with the request. |
| * @return The result of the request. |
| */ |
| private boolean getResultAndClear(int resultId) { |
| synchronized (mResultLock) { |
| final boolean success = waitForResultTimedLocked(resultId); |
| final boolean result = success ? mResult : false; |
| clearResultLocked(); |
| return result; |
| } |
| } |
| |
| /** |
| * Clears the result state. |
| */ |
| private void clearResultLocked() { |
| mResultId = -1; |
| mResult = false; |
| } |
| |
| /** |
| * Waits up to a given bound for a result of a request and returns it. |
| * |
| * @param resultId The result id to match the result with the request. |
| * @return Whether the result was received. |
| */ |
| private boolean waitForResultTimedLocked(int resultId) { |
| final long startTimeMillis = SystemClock.uptimeMillis(); |
| |
| if (DEBUG) { |
| Log.d(TAG, "Waiting for CVOX result with ID " + resultId + "..."); |
| } |
| |
| while (true) { |
| // Fail if we received a callback from the future. |
| if (mResultId > resultId) { |
| if (DEBUG) { |
| Log.w(TAG, "Aborted CVOX result"); |
| } |
| return false; |
| } |
| |
| final long elapsedTimeMillis = (SystemClock.uptimeMillis() - startTimeMillis); |
| |
| // Succeed if we received the callback we were expecting. |
| if (DEBUG) { |
| Log.w(TAG, "Check " + mResultId + " versus expected " + resultId); |
| } |
| if (mResultId == resultId) { |
| if (DEBUG) { |
| Log.w(TAG, "Received CVOX result after " + elapsedTimeMillis + " ms"); |
| } |
| return true; |
| } |
| |
| final long waitTimeMillis = (RESULT_TIMEOUT - elapsedTimeMillis); |
| |
| // Fail if we've already exceeded the timeout. |
| if (waitTimeMillis <= 0) { |
| if (DEBUG) { |
| Log.w(TAG, "Timed out while waiting for CVOX result"); |
| } |
| return false; |
| } |
| |
| try { |
| if (DEBUG) { |
| Log.w(TAG, "Start waiting..."); |
| } |
| mResultLock.wait(waitTimeMillis); |
| } catch (InterruptedException ie) { |
| if (DEBUG) { |
| Log.w(TAG, "Interrupted while waiting for CVOX result"); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Callback exposed to JavaScript. Handles returning the result of a |
| * request to a waiting (or potentially timed out) thread. |
| * |
| * @param id The result id of the request as a {@link String}. |
| * @param result The result of the request as a {@link String}. |
| */ |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public void onResult(String id, String result) { |
| if (DEBUG) { |
| Log.w(TAG, "Saw CVOX result of '" + result + "' for ID " + id); |
| } |
| final int resultId; |
| |
| try { |
| resultId = Integer.parseInt(id); |
| } catch (NumberFormatException e) { |
| return; |
| } |
| |
| synchronized (mResultLock) { |
| if (resultId > mResultId) { |
| mResult = Boolean.parseBoolean(result); |
| mResultId = resultId; |
| } else { |
| if (DEBUG) { |
| Log.w(TAG, "Result with ID " + resultId + " was stale vesus " + mResultId); |
| } |
| } |
| mResultLock.notifyAll(); |
| } |
| } |
| |
| /** |
| * Requests a callback to ensure that the JavaScript interface for this |
| * object has been added successfully. |
| * |
| * @param webView The web view to request a callback from. |
| * @param callbackRunnable Runnable to execute if a callback is received. |
| */ |
| public void requestCallback(WebView webView, Runnable callbackRunnable) { |
| mCallbackRunnable = callbackRunnable; |
| |
| webView.loadUrl("javascript:(function() { " + mInterfaceName + ".callback(); })();"); |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public void callback() { |
| if (mCallbackRunnable != null) { |
| mMainHandler.post(mCallbackRunnable); |
| mCallbackRunnable = null; |
| } |
| } |
| } |
| } |