/*
* Copyright (C) 2016 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.menurules;
import com.google.android.marvin.talkback.TalkBackService;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.DialogInterface.OnDismissListener;
import android.os.Build;
import android.os.Bundle;
import android.support.v4.os.BuildCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnFocusChangeListener;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.TextView.OnEditorActionListener;
import com.android.talkback.R;
import com.android.talkback.contextmenu.ContextMenuItem;
import com.android.talkback.contextmenu.ContextMenuItemBuilder;
import com.android.utils.AccessibilityNodeInfoUtils;
import com.android.utils.PerformActionUtils;
import java.util.LinkedList;
import java.util.List;
/**
* Provides a LCM item to manually enter a percentage value for seek controls.
* This functionality is only available on Android N and later.
*/
public class RuleSeekBar implements NodeMenuRule {
@Override
public boolean accept(TalkBackService service, AccessibilityNodeInfoCompat node) {
if (!BuildCompat.isAtLeastN()) {
return false;
}
return AccessibilityNodeInfoUtils.supportsAction(node,
AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS.getId());
}
@Override
public List<ContextMenuItem> getMenuItemsForNode(TalkBackService service,
ContextMenuItemBuilder menuItemBuilder, AccessibilityNodeInfoCompat node) {
List<ContextMenuItem> items = new LinkedList<>();
if (node != null) {
final ContextMenuItem setLevel = menuItemBuilder.createMenuItem(service,
Menu.NONE, R.id.seekbar_breakout_set_level, Menu.NONE,
service.getString(R.string.title_seek_bar_edit));
setLevel.setOnMenuItemClickListener(new SeekBarDialogManager(service, node));
setLevel.setSkipRefocusEvents(true);
items.add(setLevel);
}
return items;
}
@Override
public CharSequence getUserFriendlyMenuName(Context context) {
return context.getString(R.string.title_seek_bar_controls);
}
@Override
public boolean canCollapseMenu() {
return true;
}
private static int realToPercent(float real, float min, float max) {
return (int) (100.0f * (real - min) / (max - min));
}
private static float percentToReal(int percent, float min, float max) {
return min + (percent / 100.0f) * (max - min);
}
// Separate package-private method so we can test the logic.
static void setProgress(AccessibilityNodeInfoCompat node, int progress) {
RangeInfoCompat rangeInfo = node.getRangeInfo();
if (rangeInfo != null && progress >= 0 && progress <= 100) {
Bundle args = new Bundle();
args.putFloat(AccessibilityNodeInfo.ACTION_ARGUMENT_PROGRESS_VALUE,
percentToReal(progress, rangeInfo.getMin(),
rangeInfo.getMax()));
PerformActionUtils.performAction(node,
AccessibilityAction.ACTION_SET_PROGRESS.getId(),
args);
}
}
// Deals with opening the dialog from the menu item and controlling the dialog lifecycle.
private static class SeekBarDialogManager
implements MenuItem.OnMenuItemClickListener, OnDismissListener {
private static final int INVALID_VALUE = -1;
private final TalkBackService mService;
private AccessibilityNodeInfoCompat mSeekBar; // Note: not final so we can null it out.
private int mOldValue = INVALID_VALUE;
private int mValue = INVALID_VALUE;
private View mRootView;
private AlertDialog mDialog;
public SeekBarDialogManager(TalkBackService service,
AccessibilityNodeInfoCompat seekBar) {
mService = service;
mSeekBar = AccessibilityNodeInfoCompat.obtain(seekBar);
}
@Override
public boolean onMenuItemClick(MenuItem item) {
// Verify that node is OK and get the current seek control level first.
final RangeInfoCompat rangeInfo = mSeekBar.getRangeInfo();
if (rangeInfo == null) {
return false;
}
mOldValue = realToPercent(rangeInfo.getCurrent(), rangeInfo.getMin(),
rangeInfo.getMax());
mService.saveFocusedNode();
LayoutInflater inflater = LayoutInflater.from(mService);
mRootView = inflater.inflate(R.layout.seekbar_level_dialog, null);
final AlertDialog.Builder builder = new AlertDialog.Builder(mService)
.setView(mRootView)
.setTitle(mService.getString(R.string.title_seek_bar_edit))
.setPositiveButton(android.R.string.ok, null)
.setNegativeButton(android.R.string.cancel, null)
.setCancelable(true)
.setOnDismissListener(this);
mDialog = builder.create();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
mDialog.getWindow().setType(WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY);
} else {
mDialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ERROR);
}
mDialog.show();
mService.getRingerModeAndScreenMonitor().registerDialog(mDialog);
// We'd like to keep focus off of the text field until the user activates it.
final Button okButton = mDialog.getButton(DialogInterface.BUTTON_POSITIVE);
okButton.setFocusableInTouchMode(true);
okButton.requestFocus();
// Fill in the text field and restore normal input focus behavior when it gets focus.
final EditText percentage = (EditText) mRootView.findViewById(R.id.seek_bar_level);
percentage.setText(Integer.toString(mOldValue));
percentage.setOnFocusChangeListener(new OnFocusChangeListener() {
@Override
public void onFocusChange(View v, boolean hasFocus) {
if (hasFocus) {
okButton.setFocusableInTouchMode(false);
}
}
});
percentage.setOnEditorActionListener(new OnEditorActionListener() {
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
if (actionId == EditorInfo.IME_ACTION_DONE) {
submitDialog();
return true;
}
return false;
}
});
// Use our own custom listener to prevent the dialog from closing if there's an error.
okButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
submitDialog();
}
});
return true;
}
@Override
public void onDismiss(DialogInterface dialog) {
if (mSeekBar == null) {
return;
}
// This will only set the value if the user clicked "OK" because only "OK" will
// change the mValue field to not be INVALID_VALUE.
if (mValue != INVALID_VALUE && mValue != mOldValue) {
setProgress(mSeekBar, mValue);
}
mService.resetFocusedNode();
mService.getRingerModeAndScreenMonitor().unregisterDialog(dialog);
mSeekBar.recycle();
mSeekBar = null;
}
private void submitDialog() {
if (mRootView == null || mDialog == null) {
return;
}
final EditText percentage = (EditText) mRootView.findViewById(R.id.seek_bar_level);
try {
int percentValue = Integer.parseInt(percentage.getText().toString());
if (percentValue < 0 || percentValue > 100) {
throw new IndexOutOfBoundsException();
}
// Need to delay setting value until the dialog is dismissed.
mValue = percentValue;
mDialog.dismiss();
} catch (NumberFormatException | IndexOutOfBoundsException ex) {
// Set the error text popup.
CharSequence instructions = mService.getString(
R.string.value_seek_bar_dialog_instructions);
percentage.setError(instructions);
}
}
}
}