/* * Copyright (C) 2010 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.formatter; import android.content.Context; import android.content.res.Resources; import android.text.TextUtils; import android.util.Xml; import android.view.accessibility.AccessibilityEvent; import com.android.talkback.R; import com.google.android.marvin.talkback.TalkBackService; import com.android.talkback.Utterance; import com.android.utils.AccessibilityEventUtils; import com.android.utils.WebContentHandler; import org.xml.sax.SAXException; import java.util.HashMap; import java.util.Map; import java.util.regex.Pattern; /** * Formatter for web content. This is used for the built-in, non-Chrome-based WebView in Jelly Bean. * The non-Chrome-based WebView provides accessibility information that contains raw HTML * code; this formatter extracts the inner text from the HTML elements and provides it as * feedback to the user. * * Note: this class does nothing in current releases of Chrome, nor does it do anything in * older Chrome-based WebViews that used ChromeVox. Therefore it is safe to ignore this formatter * on KitKat or above. */ @SuppressWarnings("unused") public final class WebContentFormatter implements EventSpeechRule.AccessibilityEventFormatter { private static final int ACTION_SET_CURRENT_AXIS = 0; private static final int ACTION_TRAVERSE_CURRENT_AXIS = 1; private static final int ACTION_TRAVERSE_GIVEN_AXIS = 2; private static final int ACTION_PERFORM_AXIS_TRANSITION = 3; private static final int ACTION_TRAVERSE_DEFAULT_WEB_VIEW_BEHAVIOR_AXIS = 4; private static String[] sAxisNames; /** * A template to apply to markup before sending it to an XML parser. */ private static final String XML_TEMPLATE = "<?xml version=\"1.0\" encoding=\"UTF-8\" ?><div>%s</div>"; /** * Regular expression that matches all HTML tags. */ private final Pattern mStripMarkupPattern = Pattern.compile("<(.)+?>"); /** * Regular expression that matches all entity codes. */ private final Pattern mStripEntitiesPattern = Pattern.compile("&(.)+?;"); /** * Regular expression that matches all div or span tags. */ private final Pattern mStripDivSpanPattern = Pattern.compile("</?(div|span).*?>", Pattern.CASE_INSENSITIVE); /** * Regular expression that matches some common singleton tags. */ private final Pattern mCloseTagPattern = Pattern.compile("(<(img|input|br).+?)>", Pattern.CASE_INSENSITIVE); /** * A handler for processing HTML and generating output for speaking. */ private WebContentHandler mHtmlHandler = null; private final Action mTempAction = new Action(); @Override public boolean format(AccessibilityEvent event, TalkBackService context, Utterance utterance) { // for now ... lookup and announce axis transitions final CharSequence contentDescription = event.getContentDescription(); if (!TextUtils.isEmpty(contentDescription)) { final Action action = mTempAction; action.init(contentDescription.toString()); final int actionCode = mTempAction.mActionCode; if (actionCode == ACTION_PERFORM_AXIS_TRANSITION) { final String axisAnnouncement = getAxisAnnouncement( context, action.mSecondArgument); utterance.addSpoken(axisAnnouncement); // axisAnnouncement is chosen from an array and always not empty, // so always return true here. return true; } } // for now ... disregard content description final String markup = AccessibilityEventUtils.getEventAggregateText(event).toString(); final String noTags = mStripMarkupPattern.matcher(markup).replaceAll(""); final String cleaned = cleanMarkup(markup); if (mHtmlHandler == null) { final Map<String, String> htmlInputMap = loadMapFromStringArrays(context, R.array.html_input_to_desc_keys, R.array.html_input_to_desc_values); final Map<String, String> htmlRoleMap = loadMapFromStringArrays(context, R.array.html_role_to_desc_keys, R.array.html_role_to_desc_values); final Map<String, String> htmlTagMap = loadMapFromStringArrays(context, R.array.html_tag_to_desc_keys, R.array.html_tag_to_desc_values); mHtmlHandler = new WebContentHandler(htmlInputMap, htmlRoleMap, htmlTagMap); } try { Xml.parse(cleaned, mHtmlHandler); final String speech = mHtmlHandler.getOutput(); utterance.addSpoken(speech); } catch (final SAXException e) { e.printStackTrace(); utterance.addSpoken(noTags); } return !utterance.getSpoken().isEmpty(); } /** * Process HTML to remove markup that can't be handled by the SAX parser. * * @param markup Input HTML generated by system. * @return A string of cleaned HTML. */ public String cleanMarkup(String markup) { final String noDivOrSpan = mStripDivSpanPattern.matcher(markup).replaceAll(""); final String noEntities = mStripEntitiesPattern.matcher(noDivOrSpan).replaceAll(" "); final String tagsClosed = mCloseTagPattern.matcher(noEntities).replaceAll("$1/>"); return String.format(XML_TEMPLATE, tagsClosed); } /** * Gets an announcement for a navigation axis given its code. * * @param context Context for loading resources. * @param axisCode The code the the axis. * @return The axis announcement. */ private String getAxisAnnouncement(Context context, int axisCode) { if (sAxisNames == null) { sAxisNames = new String[] { context.getString(R.string.axis_character), context.getString(R.string.axis_word), context.getString(R.string.axis_sentence), context.getString(R.string.axis_heading), context.getString(R.string.axis_sibling), context.getString(R.string.axis_parent_first_child), context.getString(R.string.axis_document), context.getString(R.string.axis_default_web_view_behavior) }; } return sAxisNames[axisCode]; } /** * Loads a map of key strings to value strings from array resources. * * @param context The parent context. * @param keysResource A resource identifier for the array of key strings. * @param valuesResource A resource identifier for the array of value * strings. * @return A map of keys to values. */ private static Map<String, String> loadMapFromStringArrays(Context context, int keysResource, int valuesResource) { final Resources res = context.getResources(); final String[] keys = res.getStringArray(keysResource); final String[] values = res.getStringArray(valuesResource); if (keys.length != values.length) { throw new IllegalArgumentException("Array size mismatch"); } final Map<String, String> map = new HashMap<>(); for (int i = 0; i < keys.length; i++) { map.put(keys[i], values[i]); } return map; } /** * Represents an action. */ private class Action { private static final int ACTION_OFFSET = 24; private static final int ACTION_MASK = 0xFF000000; private static final int FIRST_ARGUMENT_OFFSET = 16; private static final int FIRST_ARGUMENT_MASK = 0x00FF0000; private static final int SECOND_ARGUMENT_OFFSET = 8; private static final int SECOND_ARGUMENT_MASK = 0x0000FF00; private static final int THIRD_ARGUMENT_MASK = 0x000000FF; private int mActionCode; private int mFirstArgument; private int mSecondArgument; private int mThirdArgument; public void init(String encodedActionString) { int encodedAction; try { // hack encodedAction = Integer.decode("0x" + encodedActionString); } catch (final NumberFormatException nfe) { return; } mActionCode = (encodedAction & ACTION_MASK) >> ACTION_OFFSET; mFirstArgument = (encodedAction & FIRST_ARGUMENT_MASK) >> FIRST_ARGUMENT_OFFSET; mSecondArgument = (encodedAction & SECOND_ARGUMENT_MASK) >> SECOND_ARGUMENT_OFFSET; mThirdArgument = (encodedAction & THIRD_ARGUMENT_MASK); } } }