/*
* Copyright (C) 2015 Google Inc.
*
* 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.switchaccess;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Handler;
import android.preference.PreferenceManager;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.util.DisplayMetrics;
import android.util.TypedValue;
import com.android.talkback.R;
import com.android.utils.SharedPreferencesUtils;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Manages options in a tree of {@code OptionScanNodes} and traverses them as options are
* selected.
*/
public class OptionManager implements SharedPreferences.OnSharedPreferenceChangeListener {
public static final int OPTION_INDEX_CLICK = 0;
public static final int OPTION_INDEX_NEXT = 1;
private final OverlayController mOverlayController;
private final List<OptionManagerListener> mOptionManagerListeners = new ArrayList<>();
/* TODO Clean up managing the styling information */
private final Paint[] mOptionPaintArray;
private final String[] mHighlightColorPrefKeys;
private final String[] mHighlightColorDefaults;
private final String[] mHighlightWeightPrefKeys;
private OptionScanNode mRootNode = null;
private OptionScanNode mCurrentNode = null;
private boolean mOptionScanningEnabled = false;
private ScanListener mScanListener;
private boolean mStartScanAutomatically = false;
/**
* @param overlayController The controller for the overlay on which to present options
*/
public OptionManager(OverlayController overlayController) {
mOverlayController = overlayController;
Context context = mOverlayController.getContext();
SharedPreferences prefs = SharedPreferencesUtils.getSharedPreferences(context);
mHighlightColorPrefKeys = context.getResources()
.getStringArray(R.array.switch_access_highlight_color_pref_keys);
mHighlightColorDefaults = context.getResources()
.getStringArray(R.array.switch_access_highlight_color_defaults);
mHighlightWeightPrefKeys = context.getResources()
.getStringArray(R.array.switch_access_highlight_weight_pref_keys);
mOptionPaintArray = new Paint[mHighlightColorPrefKeys.length];
for (int i = 0; i < mOptionPaintArray.length; i++) {
mOptionPaintArray[i] = new Paint();
mOptionPaintArray[i].setStyle(Paint.Style.STROKE);
}
onSharedPreferenceChanged(prefs, null);
prefs.registerOnSharedPreferenceChangeListener(this);
}
/**
* Clean up when this object is no longer needed
*/
public void shutdown() {
SharedPreferences prefs = SharedPreferencesUtils.getSharedPreferences(
mOverlayController.getContext());
prefs.unregisterOnSharedPreferenceChangeListener(this);
if (mRootNode != null) {
mRootNode.recycle();
}
mRootNode = null;
}
/**
* Clear any traversal in progress and use the new tree for future traversals
* @param newTreeRoot The root of the tree to traverse next
*/
public void clearFocusIfNewTree(OptionScanNode newTreeRoot) {
if (mRootNode == newTreeRoot) {
return;
}
if (newTreeRoot != null && newTreeRoot.equals(mRootNode)) {
newTreeRoot.recycle();
return;
}
// new tree is different
clearFocus();
if (mRootNode != null) {
mRootNode.recycle();
}
mRootNode = newTreeRoot;
if (mStartScanAutomatically) {
selectOption(0);
for (int i = 0; i < mOptionManagerListeners.size(); i++) {
OptionManagerListener listener = mOptionManagerListeners.get(i);
listener.onOptionManagerStartedAutoScan();
}
}
}
/**
* Traverse to the child node of the current node that has the specified index and take
* whatever action is appropriate for that node. If nothing currently has focus, any
* option moves to the root of the tree.
* @param optionIndex The index of the child to traverse to. Out-of-bounds indices, such as
* negative values or those above the index of the last child, cause focus to be reset.
*/
public void selectOption(int optionIndex) {
if (optionIndex < 0) {
clearFocus();
return;
}
/* Move to desired node */
if (mCurrentNode == null) {
if (mScanListener != null) {
mScanListener.onScanStart();
}
mCurrentNode = mRootNode;
} else {
if (!(mCurrentNode instanceof OptionScanSelectionNode)) {
/* This should never happen */
clearFocus();
return;
}
OptionScanSelectionNode selectionNode = (OptionScanSelectionNode) mCurrentNode;
if (optionIndex >= selectionNode.getChildCount()) {
// User pressed an option-scan switch for an index greater than this node's order
if (mScanListener != null) {
mScanListener.onScanCompletedWithNoSelection();
}
clearFocus();
return;
}
mCurrentNode = selectionNode.getChild(optionIndex);
}
onNodeFocused();
}
/**
* Move up the tree to the parent of the current node.
* @param wrap Controls wrapping when the parent is null. If {@code false}, the current node
* will not change if the parent is null. If {@code true}, a node from the bottom of the
* tree will be used instead of a null parent. The bottom node is chosen as the last
* OptionScanSelectionNode found by repeatedly selecting {@code OPTION_INDEX_NEXT}. Note that
* this result makes sense for most traditional scanning methods, but may not make perfect
* sense for all trees.
*/
public void moveToParent(boolean wrap) {
if (mCurrentNode != null) {
mCurrentNode = mCurrentNode.getParent();
if (mCurrentNode == null) {
clearFocus();
} else {
onNodeFocused();
}
return;
} else if (!wrap) {
return;
}
mCurrentNode = findLastSelectionNode();
if (mCurrentNode == null) {
clearFocus();
} else {
onNodeFocused();
}
}
/**
* Register a listener to be notified when focus is cleared
* @param optionManagerListener A listener that should be called when focus is cleared
*/
public void addOptionManagerListener(OptionManagerListener optionManagerListener) {
mOptionManagerListeners.add(optionManagerListener);
}
/**
* Support legacy long click key action.
* Perform a long click on the currently selected item, if that is possible. Long click is
* possible only if an AccessibilityNodeActionNode is the only thing highlighted, and if
* the corresponding AccessibilityNodeInfo accepts the long click action.
* If the long click goes through, reset the focus.
*/
public void performLongClick() {
SwitchAccessNodeCompat compat = findCurrentlyActiveNode();
if (compat != null) {
if (compat.performAction(AccessibilityNodeInfoCompat.ACTION_LONG_CLICK)) {
clearFocus();
}
compat.recycle();
}
}
/**
* Support legacy scroll key actions.
* Perform a scroll on the currently selected item, if it is scrollable, or a scrollable parent
* if one can be found. If the scroll action is accepted, focus is cleared.
* @param scrollAction Either {@code AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD} or
* {@code AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD}.
*/
public void performScrollAction(int scrollAction) {
SwitchAccessNodeCompat compat = findCurrentlyActiveNode();
while (compat != null) {
if (compat.isScrollable()) {
if (compat.performAction(scrollAction)) {
clearFocus();
}
compat.recycle();
return;
}
SwitchAccessNodeCompat parent = compat.getParent();
compat.recycle();
compat = parent;
}
}
@Override
public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
Context context = mOverlayController.getContext();
mOptionScanningEnabled = SwitchAccessPreferenceActivity.isOptionScanningEnabled(context);
String defaultWeight = context.getString(R.string.pref_highlight_weight_default);
/* Configure highlighting */
for (int i = 0; i < mOptionPaintArray.length; ++i) {
/*
* Always configure element 0 based on preferences. Only configure the others if we're
* option scanning.
*/
if ((i == 0) || mOptionScanningEnabled) {
String hexStringColor =
prefs.getString(mHighlightColorPrefKeys[i], mHighlightColorDefaults[i]);
int color = Integer.parseInt(hexStringColor, 16);
mOptionPaintArray[i].setColor(color);
mOptionPaintArray[i].setAlpha(255);
String stringWeight = prefs.getString(mHighlightWeightPrefKeys[i], defaultWeight);
int weight = Integer.valueOf(stringWeight);
DisplayMetrics dm = context.getResources().getDisplayMetrics();
float strokeWidth =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, weight, dm);
mOptionPaintArray[i].setStrokeWidth(strokeWidth);
} else {
mOptionPaintArray[i].setColor(Color.TRANSPARENT);
}
}
mStartScanAutomatically = prefs.getBoolean(
context.getString(R.string.switch_access_auto_start_scan_key), false);
}
/**
* Register a listener to notify of auto-scan activity
* @param listener the listener to be set
*/
public void setScanListener(ScanListener listener) {
mScanListener = listener;
}
private void clearFocus() {
mCurrentNode = null;
mOverlayController.clearOverlay();
for (OptionManagerListener listener : mOptionManagerListeners) {
listener.onOptionManagerClearedFocus();
}
}
private void onNodeFocused() {
if (mScanListener != null) {
if (mCurrentNode instanceof ClearFocusNode) {
mScanListener.onScanCompletedWithNoSelection();
} else if (mCurrentNode instanceof OptionScanActionNode) {
mScanListener.onScanSelection();
} else {
mScanListener.onScanFocusChanged();
}
}
mCurrentNode.performAction();
/* TODO Any items that we want drawn on the screen could be directly grouped
* into option groups when the tree is being constructed. That way the drawing of the
* button or any other items would be data driven. */
if (mCurrentNode instanceof OptionScanSelectionNode) {
mOverlayController.clearOverlay();
final OptionScanSelectionNode selectionNode = (OptionScanSelectionNode) mCurrentNode;
if (mOptionScanningEnabled) {
mOverlayController.drawMenuButton();
/* showSelections() needs to know the location of the button in the screen to
* highlight it. Hence run it a handler to give the thread a chance to draw the
* overlay.
*/
new Handler().post(new Runnable() {
@Override
public void run() {
selectionNode.showSelections(mOverlayController, mOptionPaintArray);
}
});
} else {
selectionNode.showSelections(mOverlayController, mOptionPaintArray);
}
} else {
clearFocus();
}
}
/*
* Find exactly one {@code SwitchAccessNodeCompat} in the current tree
* @return an {@code obtain}ed SwitchAccessNodeCompat if there is exactly one in the
* current tree. Returns {@code null} otherwise.
*/
private SwitchAccessNodeCompat findCurrentlyActiveNode() {
if (!(mCurrentNode instanceof OptionScanSelectionNode)) {
return null;
}
OptionScanNode startNode = ((OptionScanSelectionNode) mCurrentNode)
.getChild(OPTION_INDEX_CLICK);
Set<AccessibilityNodeActionNode> nodeSet = new HashSet<>();
addAccessibilityNodeActionNodesToSet(startNode, nodeSet);
SwitchAccessNodeCompat compat = null;
for (AccessibilityNodeActionNode actionNode : nodeSet) {
SwitchAccessNodeCompat actionNodeCompat = actionNode.getNodeInfoCompat();
if (actionNodeCompat == null) {
continue; // Should never happen
}
if (compat == null) {
compat = actionNodeCompat;
} else if (compat.equals(actionNodeCompat)) {
actionNodeCompat.recycle();
} else {
compat.recycle();
actionNodeCompat.recycle();
return null;
}
}
return compat;
}
/*
* Find all AccessibilityNodeActionNodes in the tree rooted at the current selection
*/
private void addAccessibilityNodeActionNodesToSet(
OptionScanNode startNode, Set<AccessibilityNodeActionNode> nodeSet) {
if (startNode instanceof AccessibilityNodeActionNode) {
nodeSet.add((AccessibilityNodeActionNode) startNode);
}
if (startNode instanceof OptionScanSelectionNode) {
OptionScanSelectionNode selectionNode = (OptionScanSelectionNode) startNode;
for (int i = 0; i < selectionNode.getChildCount(); ++i) {
addAccessibilityNodeActionNodesToSet(selectionNode.getChild(i), nodeSet);
}
}
}
private OptionScanNode findLastSelectionNode() {
OptionScanNode newNode = mRootNode;
if (!(newNode instanceof OptionScanSelectionNode)) {
return null;
}
OptionScanNode possibleNewNode
= ((OptionScanSelectionNode) newNode).getChild(OPTION_INDEX_NEXT);
while (possibleNewNode instanceof OptionScanSelectionNode) {
newNode = possibleNewNode;
possibleNewNode = ((OptionScanSelectionNode) newNode).getChild(OPTION_INDEX_NEXT);
}
return newNode;
}
/**
* Interface to monitor when focus is cleared
*/
public interface OptionManagerListener {
/** Called when scanning is automatically started */
void onOptionManagerStartedAutoScan();
/** Called when focus clears */
void onOptionManagerClearedFocus();
}
/**
* Interface to monitor the user's progress of scanning to desired items
*/
public interface ScanListener {
/** Called when scanning starts and the first highlighting is drawn */
void onScanStart();
/** Called when scanning reaches a new selection node and highlighting changes */
void onScanFocusChanged();
/** Called when scanning reaches an action node and an action is taken */
void onScanSelection();
/** Called when scanning completes without any action being taken */
void onScanCompletedWithNoSelection();
}
}