| // Copyright 2010-2014, Google Inc. |
| // All rights reserved. |
| // |
| // Redistribution and use in source and binary forms, with or without |
| // modification, are permitted provided that the following conditions are |
| // met: |
| // |
| // * Redistributions of source code must retain the above copyright |
| // notice, this list of conditions and the following disclaimer. |
| // * Redistributions in binary form must reproduce the above |
| // copyright notice, this list of conditions and the following disclaimer |
| // in the documentation and/or other materials provided with the |
| // distribution. |
| // * Neither the name of Google Inc. nor the names of its |
| // contributors may be used to endorse or promote products derived from |
| // this software without specific prior written permission. |
| // |
| // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
| // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
| // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
| // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
| // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
| // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
| // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
| // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| package org.mozc.android.inputmethod.japanese.accessibility; |
| |
| import org.mozc.android.inputmethod.japanese.protobuf.ProtoCandidates.CandidateWord; |
| import org.mozc.android.inputmethod.japanese.ui.CandidateLayout; |
| import org.mozc.android.inputmethod.japanese.ui.CandidateLayout.Row; |
| import org.mozc.android.inputmethod.japanese.ui.CandidateLayout.Span; |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Optional; |
| import com.google.common.base.Preconditions; |
| import com.google.common.base.Strings; |
| |
| import android.annotation.SuppressLint; |
| import android.content.Context; |
| import android.graphics.Rect; |
| import android.os.Bundle; |
| import android.support.v4.view.ViewCompat; |
| import android.support.v4.view.accessibility.AccessibilityEventCompat; |
| import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; |
| import android.support.v4.view.accessibility.AccessibilityNodeProviderCompat; |
| import android.support.v4.view.accessibility.AccessibilityRecordCompat; |
| import android.util.SparseArray; |
| import android.view.View; |
| import android.view.accessibility.AccessibilityEvent; |
| |
| import javax.annotation.Nullable; |
| |
| /** |
| * Represents candidate window's virtual structure. |
| * |
| * <p>Note about virtual view ID: This class uses {@code CandidateWord}'s {@code id} |
| * as virtual view ID. |
| */ |
| class CandidateWindowAccessibilityNodeProvider extends AccessibilityNodeProviderCompat { |
| |
| @VisibleForTesting static final int UNDEFINED = Integer.MIN_VALUE; |
| @VisibleForTesting static final int FOLD_BUTTON_ID = Integer.MIN_VALUE + 1; |
| private Optional<CandidateLayout> layout = Optional.absent(); |
| // The view backed by this class. |
| private final View view; |
| // Caches only Row. Caches Span might be slow on construction. |
| private Optional<SparseArray<Row>> virtualViewIdToRow = Optional.absent(); |
| // Virtual ID of focused (in the light of accessibility) view. |
| private int virtualFocusedViewId = UNDEFINED; |
| |
| CandidateWindowAccessibilityNodeProvider(View view) { |
| this.view = Preconditions.checkNotNull(view); |
| } |
| |
| private Context getContext() { |
| return view.getContext(); |
| } |
| |
| /** |
| * Sets updated layout and resets virtual view structure. |
| */ |
| void setCandidateLayout(Optional<CandidateLayout> layout) { |
| this.layout = Preconditions.checkNotNull(layout); |
| resetVirtualStructure(); |
| } |
| |
| /** |
| * Returns a {@code Row} which contains a {@code CandidateWord} of which the |
| * {@code id} is given {@code virtualViewId}. |
| */ |
| private Optional<Row> getRow(int virtualViewId) { |
| if (!virtualViewIdToRow.isPresent()) { |
| if (!layout.isPresent()) { |
| return Optional.absent(); |
| } |
| SparseArray<Row> virtualViewIdToRow = new SparseArray<Row>(); |
| for (Row row : layout.get().getRowList()) { |
| for (Span span : row.getSpanList()) { |
| if (span.getCandidateWord().isPresent()) { |
| // - Skip reserved empty span, which is for folding button. |
| // - Use append method expecting that the id is in ascending order. |
| // Even if not, it works well. |
| virtualViewIdToRow.append(span.getCandidateWord().get().getId(), row); |
| } else { |
| virtualViewIdToRow.append(FOLD_BUTTON_ID, row); |
| } |
| } |
| } |
| this.virtualViewIdToRow = Optional.of(virtualViewIdToRow); |
| } |
| return Optional.fromNullable(virtualViewIdToRow.get().get(virtualViewId)); |
| } |
| |
| @SuppressLint("InlinedApi") |
| private Optional<AccessibilityNodeInfoCompat> createNodeInfoForId(int virtualViewId) { |
| Optional<Row> optionalRow = getRow(virtualViewId); |
| if (!optionalRow.isPresent()) { |
| return Optional.absent(); |
| } |
| Row row = optionalRow.get(); |
| for (Span span : row.getSpanList()) { |
| if (span.getCandidateWord().isPresent() |
| && span.getCandidateWord().get().getId() != virtualViewId) { |
| continue; |
| } |
| AccessibilityNodeInfoCompat info = createNodeInfoForSpan(virtualViewId, row, span); |
| info.setContentDescription(span.getCandidateWord().isPresent() |
| ? getContentDescription(span.getCandidateWord().get()) : null); |
| if (virtualFocusedViewId == virtualViewId) { |
| info.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS); |
| } else { |
| info.addAction(AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS); |
| } |
| return Optional.of(info); |
| } |
| return Optional.absent(); |
| } |
| |
| private AccessibilityNodeInfoCompat createNodeInfoForSpan(int virtualViewId, Row row, Span span) { |
| AccessibilityNodeInfoCompat info = AccessibilityNodeInfoCompat.obtain(); |
| Rect boundsInParent = |
| new Rect((int) (span.getLeft()), (int) (row.getTop()), |
| (int) (span.getRight()), (int) (row.getTop() + row.getHeight())); |
| int[] parentLocationOnScreen = new int[2]; |
| view.getLocationOnScreen(parentLocationOnScreen); |
| Rect boundsInScreen = new Rect(boundsInParent); |
| boundsInScreen.offset(parentLocationOnScreen[0], parentLocationOnScreen[1]); |
| |
| info.setPackageName(getContext().getPackageName()); |
| info.setClassName(Span.class.getName()); |
| info.setBoundsInParent(boundsInParent); |
| info.setBoundsInScreen(boundsInScreen); |
| info.setParent(view); |
| info.setSource(view, virtualViewId); |
| info.setEnabled(true); |
| info.setVisibleToUser(true); |
| return info; |
| } |
| |
| @Nullable |
| @Override |
| public AccessibilityNodeInfoCompat createAccessibilityNodeInfo(int virtualViewId) { |
| if (virtualViewId == UNDEFINED) { |
| return null; |
| } |
| if (virtualViewId == View.NO_ID) { |
| // Required to return the information about entire view. |
| AccessibilityNodeInfoCompat info = |
| AccessibilityNodeInfoCompat.obtain(view); |
| Preconditions.checkNotNull(info); |
| ViewCompat.onInitializeAccessibilityNodeInfo(view, info); |
| if (!layout.isPresent()) { |
| return info; |
| } |
| for (Row row : layout.get().getRowList()) { |
| for (Span span : row.getSpanList()) { |
| if (span.getCandidateWord().isPresent()) { |
| // Skip reserved empty span, which is for folding button. |
| info.addChild(view, span.getCandidateWord().get().getId()); |
| } else { |
| info.addChild(view, FOLD_BUTTON_ID); |
| } |
| } |
| } |
| return info; |
| } |
| return createNodeInfoForId(virtualViewId).orNull(); |
| } |
| |
| private boolean isAccessibilityEnabled() { |
| return AccessibilityUtil.isAccessibilityEnabled(getContext()); |
| } |
| |
| private void resetVirtualStructure() { |
| virtualViewIdToRow = Optional.absent(); |
| if (isAccessibilityEnabled()) { |
| AccessibilityEvent event = AccessibilityEvent.obtain(); |
| ViewCompat.onInitializeAccessibilityEvent(view, event); |
| event.setEventType(AccessibilityEventCompat.TYPE_WINDOW_CONTENT_CHANGED); |
| AccessibilityUtil.sendAccessibilityEvent(getContext(), event); |
| } |
| } |
| |
| /** |
| * Returns a {@code CandidateWord} based on given position if available. |
| * |
| * @param x horizontal location in screen coordinate (pixel) |
| * @param y vertical location in screen coordinate (pixel) |
| */ |
| Optional<CandidateWord> getCandidateWord(int x, int y) { |
| if (!layout.isPresent()) { |
| return Optional.absent(); |
| } |
| for (Row row : layout.get().getRowList()) { |
| if (y < row.getTop() || y >= row.getTop() + row.getHeight()) { |
| continue; |
| } |
| for (Span span : row.getSpanList()) { |
| if (x >= span.getLeft() && x < span.getRight()) { |
| return span.getCandidateWord(); |
| } |
| } |
| } |
| return Optional.absent(); |
| } |
| |
| void sendAccessibilityEventForCandidateWordIfAccessibilityEnabled(CandidateWord candidateWord, |
| int eventType) { |
| if (isAccessibilityEnabled()) { |
| AccessibilityEvent event = createAccessibilityEvent(candidateWord, eventType); |
| AccessibilityUtil.sendAccessibilityEvent(getContext(), event); |
| } |
| } |
| |
| private AccessibilityEvent createAccessibilityEvent(CandidateWord candidateWord, |
| int eventType) { |
| Preconditions.checkNotNull(candidateWord); |
| |
| AccessibilityEvent event = AccessibilityEvent.obtain(eventType); |
| event.setPackageName(getContext().getPackageName()); |
| event.setClassName(candidateWord.getClass().getName()); |
| event.setContentDescription(getContentDescription(candidateWord)); |
| event.setEnabled(true); |
| AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event); |
| record.setSource(view, candidateWord.getId()); |
| return event; |
| } |
| |
| /** |
| * Returns content description based on value and annotation. |
| */ |
| private String getContentDescription(CandidateWord candidateWord) { |
| Preconditions.checkNotNull(candidateWord); |
| String contentDescription = Strings.nullToEmpty(candidateWord.getValue()); |
| if (candidateWord.hasAnnotation() && candidateWord.getAnnotation().hasDescription()) { |
| contentDescription += " " + candidateWord.getAnnotation().getDescription(); |
| } |
| return contentDescription; |
| } |
| |
| private Optional<CandidateWord> getCandidateWordFromId(int id) { |
| Optional<Row> optionalRow = getRow(id); |
| if (!optionalRow.isPresent()) { |
| return Optional.absent(); |
| } |
| Row row = optionalRow.get(); |
| for (Span span : row.getSpanList()) { |
| Optional<CandidateWord> candidateWord = span.getCandidateWord(); |
| if (candidateWord.isPresent() && candidateWord.get().getId() == id) { |
| return candidateWord; |
| } |
| } |
| return Optional.absent(); |
| } |
| |
| @Override |
| public boolean performAction(int virtualViewId, int action, Bundle arguments) { |
| Optional<CandidateWord> candidateWord = getCandidateWordFromId(virtualViewId); |
| return candidateWord.isPresent() |
| ? performActionForCandidateWordInternal(candidateWord.get(), virtualViewId, action) |
| : false; |
| } |
| |
| boolean performActionForCandidateWord(CandidateWord candidateWord, |
| int actionAccessibilityFocus) { |
| Preconditions.checkNotNull(candidateWord); |
| return performActionForCandidateWordInternal(candidateWord, candidateWord.getId(), |
| actionAccessibilityFocus); |
| } |
| |
| private boolean performActionForCandidateWordInternal(CandidateWord candidateWord, |
| int virtualViewId, int action) { |
| Preconditions.checkNotNull(candidateWord); |
| |
| switch (action) { |
| case AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS: |
| if (virtualFocusedViewId == virtualViewId) { |
| // If focused virtual view is unchanged, do nothing. |
| return false; |
| } |
| // Framework requires the candidate window to have focus. |
| // Return FOCUSED event to the framework as response. |
| virtualFocusedViewId = virtualViewId; |
| if (isAccessibilityEnabled()) { |
| AccessibilityUtil.sendAccessibilityEvent( |
| getContext(), |
| createAccessibilityEvent( |
| candidateWord, |
| AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED)); |
| } |
| return true; |
| case AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS: |
| // Framework requires the candidate window to clear focus. |
| // Return FOCUSE_CLEARED event to the framework as response. |
| if (virtualFocusedViewId != virtualViewId) { |
| return false; |
| } |
| virtualFocusedViewId = UNDEFINED; |
| if (isAccessibilityEnabled()) { |
| AccessibilityUtil.sendAccessibilityEvent( |
| getContext(), |
| createAccessibilityEvent( |
| candidateWord, |
| AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED)); |
| } |
| return true; |
| default: |
| return false; |
| } |
| } |
| } |