/* * Copyright (C) 2015 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 com.android.talkback.eventprocessor; import android.content.Context; import android.content.SharedPreferences; import android.os.Build; import android.os.Message; import android.support.v4.view.accessibility.AccessibilityEventCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; import android.support.v4.view.accessibility.AccessibilityRecordCompat; import android.text.TextUtils; import android.util.Log; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityNodeInfo; import android.view.accessibility.AccessibilityWindowInfo; import android.view.inputmethod.InputMethodManager; import android.view.inputmethod.InputMethodSubtype; import com.android.talkback.FeedbackItem; import com.android.talkback.R; import com.android.talkback.SpeechController; import com.android.utils.AccessibilityEventListener; import com.android.utils.AccessibilityEventUtils; import com.android.utils.AccessibilityNodeInfoUtils; import com.android.utils.LogUtils; import com.android.utils.SharedPreferencesUtils; import com.android.utils.WeakReferenceHandler; import com.android.utils.WindowManager; import com.google.android.marvin.talkback.TalkBackService; import org.json.JSONException; import org.json.JSONObject; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.util.HashMap; import java.util.Iterator; import java.util.Locale; import java.util.Map; /** * Manages phonetic letters. If the user waits on a key or selected character, * the word from the phonetic alphabet that represents it. */ public class ProcessorPhoneticLetters implements AccessibilityEventListener { private static final String FALLBACK_LOCALE = "en_US"; private final SharedPreferences mPrefs; private final TalkBackService mService; private final SpeechController mSpeechController; private final PhoneticLetterHandler mHandler; // Maps Language -> letter -> Phonetic letter. private Map<String, Map<String, String>> mPhoneticLetters = new HashMap<String, Map<String, String>>(); public ProcessorPhoneticLetters(TalkBackService service, SpeechController speechController) { if (speechController == null) throw new IllegalStateException(); mPrefs = SharedPreferencesUtils.getSharedPreferences(service); mService = service; mSpeechController = speechController; mHandler = new PhoneticLetterHandler(this); } @Override public void onAccessibilityEvent(AccessibilityEvent event) { if (shouldCancelPhoneticLetter(event)) { cancelPhoneticLetter(); } if (!arePhoneticLettersEnabled()) return; if (isKeyboardEvent(event)) processKeyboardKeyEvent(event); if (AccessibilityEventUtils.isCharacterTraversalEvent(event)) processTraversalEvent(event); } /** * Handle an event that indicates a key is held on the soft keyboard. */ private void processKeyboardKeyEvent(AccessibilityEvent event) { final CharSequence text = AccessibilityEventUtils.getEventTextOrDescription(event); if (TextUtils.isEmpty(text)) { return; } String localeString = FALLBACK_LOCALE; InputMethodManager inputMethodManager = (InputMethodManager) mService.getSystemService(Context.INPUT_METHOD_SERVICE); InputMethodSubtype inputMethod = inputMethodManager.getCurrentInputMethodSubtype(); if (inputMethod != null) { localeString = inputMethod.getLocale(); } String phoneticLetter = getPhoneticLetter(localeString, text.toString()); if (phoneticLetter != null) { postPhoneticLetterRunnable(phoneticLetter); } } private boolean arePhoneticLettersEnabled() { return SharedPreferencesUtils.getBooleanPref( mPrefs, mService.getResources(), R.string.pref_phonetic_letters_key, R.bool.pref_phonetic_letters_default); } private boolean isKeyboardEvent(AccessibilityEvent event) { if (event.getEventType() != AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) { return false; } if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) { // For platform since lollipop, check that the current window is an // Input Method. final AccessibilityNodeInfo source = event.getSource(); if (source == null) { return false; } int windowId = source.getWindowId(); WindowManager manager = new WindowManager(mService.isScreenLayoutRTL()); manager.setWindows(mService.getWindows()); return manager.getWindowType(windowId) == AccessibilityWindowInfo.TYPE_INPUT_METHOD; } else { // For old platforms, we can't check the window type directly, so just // manually check the classname. if (event.getClassName() != null) { return event.getClassName().equals("com.android.inputmethod.keyboard.Key"); } else { return false; } } } /** * Handle an event that indicates a text is being traversed at character * granularity. */ private void processTraversalEvent(AccessibilityEvent event) { final CharSequence text = AccessibilityEventUtils.getEventTextOrDescription(event); if (TextUtils.isEmpty(text)) { return; } String letter; if ((event.getAction() == AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY || event.getAction() == AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY) && event.getFromIndex() >= 0 && event.getFromIndex() < text.length()) { letter = String.valueOf(text.charAt(event.getFromIndex())); } else { return; } String phoneticLetter = getPhoneticLetter(Locale.getDefault().toString(), letter); if (phoneticLetter != null) { postPhoneticLetterRunnable(phoneticLetter); } } /** * Get the Locale from a language tag's language and country. * The variant is discarded. Returns Locale.ENGLISH on failure. */ static Locale parseLanguageTag(String languageTag) { String localeParts[] = languageTag.split("_", 3); if (localeParts.length >= 2) { return new Locale(localeParts[0], localeParts[1]); } else if (localeParts.length >= 1) { return new Locale(localeParts[0]); } else { return Locale.ENGLISH; } } /** * Map a character to a phonetic letter. */ private String getPhoneticLetter(String locale, String letter) { Locale bcp47_locale = parseLanguageTag(locale); String normalized_letter = letter.toLowerCase(bcp47_locale); String value = getPhoneticLetterMap(locale).get(normalized_letter); if (value == null) { if (bcp47_locale.getCountry().isEmpty()) { // As a last resort, fall back to English. value = getPhoneticLetterMap(FALLBACK_LOCALE).get(normalized_letter); } else { // Get the letter for the base language, if possible. value = getPhoneticLetter(bcp47_locale.getLanguage(), normalized_letter); } } return value; } /** * Get the mapping from letter to phonetic letter for a given locale. * The map is loaded as needed. */ private Map<String, String> getPhoneticLetterMap(String locale) { Map<String, String> map = mPhoneticLetters.get(locale); if (map == null) { // If there is no entry for the local, the map will be left // empty. This prevents future load attempts for that locale. map = new HashMap<String, String>(); mPhoneticLetters.put(locale, map); InputStream stream = mService.getResources().openRawResource(R.raw.phonetic_letters); BufferedReader reader = null; try { reader = new BufferedReader(new InputStreamReader(stream, "UTF-8")); StringBuilder stringBuilder = new StringBuilder(); String input; while ((input = reader.readLine()) != null) { stringBuilder.append(input); } stream.close(); JSONObject locales = new JSONObject(stringBuilder.toString()); JSONObject phoneticLetters = locales.getJSONObject(locale); if (phoneticLetters != null) { Iterator<?> keys = phoneticLetters.keys(); while (keys.hasNext()) { String letter = (String) keys.next(); map.put(letter, phoneticLetters.getString(letter)); } } } catch (java.io.IOException e) { LogUtils.log(this, Log.ERROR, e.toString()); } catch (JSONException e) { LogUtils.log(this, Log.ERROR, e.toString()); } } return map; } /** * Returns true if a pending phonetic letter should be interrupted. */ private boolean shouldCancelPhoneticLetter(AccessibilityEvent event) { return event.getEventType() != AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && event.getEventType() != AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED && event.getEventType() != AccessibilityEvent.TYPE_VIEW_LONG_CLICKED && event.getEventType() != AccessibilityEvent.TYPE_ANNOUNCEMENT; } /** * Starts the phonetic letter timeout. Call this whenever a letter has * been paused on. */ private void postPhoneticLetterRunnable(String phoneticLetter) { mHandler.startPhoneticLetterTimeout(phoneticLetter); } /** * Removes the phonetic letter timeout and completion action. */ private void cancelPhoneticLetter() { mHandler.cancelPhoneticLetterTimeout(); } private static class PhoneticLetterHandler extends WeakReferenceHandler<ProcessorPhoneticLetters> { /** * Message identifier for a phonetic letter notification. */ private static final int PHONETIC_LETTER_TIMEOUT = 1; /** * Timeout before reading a phonetic letter. */ private static final long DELAY_PHONETIC_LETTER_TIMEOUT = 1000; public PhoneticLetterHandler(ProcessorPhoneticLetters parent) { super(parent); } @Override public void handleMessage(Message msg, ProcessorPhoneticLetters parent) { switch (msg.what) { case PHONETIC_LETTER_TIMEOUT: { final String phoneticLetter = (String) msg.obj; // Use QUEUE mode so that we don't interrupt more important messages. parent.mSpeechController.speak( phoneticLetter, SpeechController.QUEUE_MODE_QUEUE, FeedbackItem.FLAG_NO_HISTORY, null); break; } } } public void startPhoneticLetterTimeout(String phoneticLetter) { final Message msg = obtainMessage(PHONETIC_LETTER_TIMEOUT, phoneticLetter); sendMessageDelayed(msg, DELAY_PHONETIC_LETTER_TIMEOUT); } public void cancelPhoneticLetterTimeout() { removeMessages(PHONETIC_LETTER_TIMEOUT); } } }