1 /* 2 * Copyright (C) 2012 Google Inc. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 * use this file except in compliance with the License. You may obtain a copy of 6 * the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 * License for the specific language governing permissions and limitations under 14 * the License. 15 */ 16 17 package com.googlecode.eyesfree.utils; 18 19 import android.content.Context; 20 import android.graphics.Rect; 21 import android.os.Bundle; 22 import android.support.v4.view.AccessibilityDelegateCompat; 23 import android.support.v4.view.ViewCompat; 24 import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; 25 import android.support.v4.view.accessibility.AccessibilityNodeProviderCompat; 26 import android.support.v4.view.accessibility.AccessibilityRecordCompat; 27 import android.text.TextUtils; 28 import android.view.MotionEvent; 29 import android.view.View; 30 import android.view.ViewGroup; 31 import android.view.accessibility.AccessibilityEvent; 32 import android.view.accessibility.AccessibilityManager; 33 34 import java.util.LinkedList; 35 import java.util.List; 36 37 public abstract class TouchExplorationHelper<T> extends AccessibilityNodeProviderCompat 38 implements View.OnHoverListener { 39 /** Virtual node identifier value for invalid nodes. */ 40 public static final int INVALID_ID = Integer.MIN_VALUE; 41 42 private final Rect mTempScreenRect = new Rect(); 43 private final Rect mTempParentRect = new Rect(); 44 private final Rect mTempVisibleRect = new Rect(); 45 private final int[] mTempGlobalRect = new int[2]; 46 47 private final AccessibilityManager mManager; 48 49 private View mParentView; 50 private int mFocusedItemId = INVALID_ID; 51 private T mCurrentItem = null; 52 53 /** 54 * Constructs a new touch exploration helper. 55 * 56 * @param context The parent context. 57 */ 58 public TouchExplorationHelper(Context context, View parentView) { 59 mManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); 60 mParentView = parentView; 61 } 62 63 /** 64 * @return The current accessibility focused item, or {@code null} if no 65 * item is focused. 66 */ 67 public T getFocusedItem() { 68 return getItemForId(mFocusedItemId); 69 } 70 71 /** 72 * Clears the current accessibility focused item. 73 */ 74 public void clearFocusedItem() { 75 final int itemId = mFocusedItemId; 76 if (itemId == INVALID_ID) { 77 return; 78 } 79 80 performAction(itemId, AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS, null); 81 } 82 83 /** 84 * Requests accessibility focus be placed on the specified item. 85 * 86 * @param item The item to place focus on. 87 */ 88 public void setFocusedItem(T item) { 89 final int itemId = getIdForItem(item); 90 if (itemId == INVALID_ID) { 91 return; 92 } 93 94 performAction(itemId, AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS, null); 95 } 96 97 /** 98 * Invalidates cached information about the parent view. 99 * <p> 100 * You <b>must</b> call this method after adding or removing items from the 101 * parent view. 102 * </p> 103 */ 104 public void invalidateParent() { 105 mParentView.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); 106 } 107 108 /** 109 * Invalidates cached information for a particular item. 110 * <p> 111 * You <b>must</b> call this method when any of the properties set in 112 * {@link #populateNodeForItem(Object, AccessibilityNodeInfoCompat)} have 113 * changed. 114 * </p> 115 * 116 * @param item 117 */ 118 public void invalidateItem(T item) { 119 sendEventForItem(item, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); 120 } 121 122 /** 123 * Populates an event of the specified type with information about an item 124 * and attempts to send it up through the view hierarchy. 125 * 126 * @param item The item for which to send an event. 127 * @param eventType The type of event to send. 128 * @return {@code true} if the event was sent successfully. 129 */ 130 public boolean sendEventForItem(T item, int eventType) { 131 if (!mManager.isEnabled()) { 132 return false; 133 } 134 135 final AccessibilityEvent event = getEventForItem(item, eventType); 136 final ViewGroup group = (ViewGroup) mParentView.getParent(); 137 138 return group.requestSendAccessibilityEvent(mParentView, event); 139 } 140 141 @Override 142 public AccessibilityNodeInfoCompat createAccessibilityNodeInfo(int virtualViewId) { 143 if (virtualViewId == View.NO_ID) { 144 return getNodeForParent(); 145 } 146 147 final T item = getItemForId(virtualViewId); 148 if (item == null) { 149 return null; 150 } 151 152 final AccessibilityNodeInfoCompat node = AccessibilityNodeInfoCompat.obtain(); 153 populateNodeForItemInternal(item, node); 154 return node; 155 } 156 157 @Override 158 public boolean performAction(int virtualViewId, int action, Bundle arguments) { 159 if (virtualViewId == View.NO_ID) { 160 return ViewCompat.performAccessibilityAction(mParentView, action, arguments); 161 } 162 163 final T item = getItemForId(virtualViewId); 164 if (item == null) { 165 return false; 166 } 167 168 boolean handled = false; 169 170 switch (action) { 171 case AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS: 172 if (mFocusedItemId != virtualViewId) { 173 mFocusedItemId = virtualViewId; 174 sendEventForItem(item, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); 175 handled = true; 176 } 177 break; 178 case AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS: 179 if (mFocusedItemId == virtualViewId) { 180 mFocusedItemId = INVALID_ID; 181 sendEventForItem(item, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); 182 handled = true; 183 } 184 break; 185 } 186 187 handled |= performActionForItem(item, action, arguments); 188 189 return handled; 190 } 191 192 @Override 193 public boolean onHover(View view, MotionEvent event) { 194 if (!mManager.isTouchExplorationEnabled()) { 195 return false; 196 } 197 198 switch (event.getAction()) { 199 case MotionEvent.ACTION_HOVER_ENTER: 200 case MotionEvent.ACTION_HOVER_MOVE: 201 final T item = getItemAt(event.getX(), event.getY()); 202 setCurrentItem(item); 203 return true; 204 case MotionEvent.ACTION_HOVER_EXIT: 205 setCurrentItem(null); 206 return true; 207 } 208 209 return false; 210 } 211 212 private void setCurrentItem(T item) { 213 if (mCurrentItem == item) { 214 return; 215 } 216 217 if (mCurrentItem != null) { 218 sendEventForItem(mCurrentItem, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT); 219 } 220 221 mCurrentItem = item; 222 223 if (mCurrentItem != null) { 224 sendEventForItem(mCurrentItem, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER); 225 } 226 } 227 228 private AccessibilityEvent getEventForItem(T item, int eventType) { 229 final AccessibilityEvent event = AccessibilityEvent.obtain(eventType); 230 final AccessibilityRecordCompat record = new AccessibilityRecordCompat(event); 231 final int virtualDescendantId = getIdForItem(item); 232 233 // Ensure the client has good defaults. 234 event.setEnabled(true); 235 236 // Allow the client to populate the event. 237 populateEventForItem(item, event); 238 239 if (event.getText().isEmpty() && TextUtils.isEmpty(event.getContentDescription())) { 240 throw new RuntimeException( 241 "You must add text or a content description in populateEventForItem()"); 242 } 243 244 // Don't allow the client to override these properties. 245 event.setClassName(item.getClass().getName()); 246 event.setPackageName(mParentView.getContext().getPackageName()); 247 record.setSource(mParentView, virtualDescendantId); 248 249 return event; 250 } 251 252 private AccessibilityNodeInfoCompat getNodeForParent() { 253 final AccessibilityNodeInfoCompat info = AccessibilityNodeInfoCompat.obtain(mParentView); 254 ViewCompat.onInitializeAccessibilityNodeInfo(mParentView, info); 255 256 final LinkedList<T> items = new LinkedList<T>(); 257 getVisibleItems(items); 258 259 for (T item : items) { 260 final int virtualDescendantId = getIdForItem(item); 261 info.addChild(mParentView, virtualDescendantId); 262 } 263 264 return info; 265 } 266 267 private AccessibilityNodeInfoCompat populateNodeForItemInternal( 268 T item, AccessibilityNodeInfoCompat node) { 269 final int virtualDescendantId = getIdForItem(item); 270 271 // Ensure the client has good defaults. 272 node.setEnabled(true); 273 274 // Allow the client to populate the node. 275 populateNodeForItem(item, node); 276 277 if (TextUtils.isEmpty(node.getText()) && TextUtils.isEmpty(node.getContentDescription())) { 278 throw new RuntimeException( 279 "You must add text or a content description in populateNodeForItem()"); 280 } 281 282 // Don't allow the client to override these properties. 283 node.setPackageName(mParentView.getContext().getPackageName()); 284 node.setClassName(item.getClass().getName()); 285 node.setParent(mParentView); 286 node.setSource(mParentView, virtualDescendantId); 287 288 if (mFocusedItemId == virtualDescendantId) { 289 node.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS); 290 } else { 291 node.addAction(AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS); 292 } 293 294 node.getBoundsInParent(mTempParentRect); 295 if (mTempParentRect.isEmpty()) { 296 throw new RuntimeException("You must set parent bounds in populateNodeForItem()"); 297 } 298 299 // Set the visibility based on the parent bound. 300 if (intersectVisibleToUser(mTempParentRect)) { 301 node.setVisibleToUser(true); 302 node.setBoundsInParent(mTempParentRect); 303 } 304 305 // Calculate screen-relative bound. 306 mParentView.getLocationOnScreen(mTempGlobalRect); 307 final int offsetX = mTempGlobalRect[0]; 308 final int offsetY = mTempGlobalRect[1]; 309 mTempScreenRect.set(mTempParentRect); 310 mTempScreenRect.offset(offsetX, offsetY); 311 node.setBoundsInScreen(mTempScreenRect); 312 313 return node; 314 } 315 316 /** 317 * Computes whether the specified {@link Rect} intersects with the visible 318 * portion of its parent {@link View}. Modifies {@code localRect} to 319 * contain only the visible portion. 320 * 321 * @param localRect A rectangle in local (parent) coordinates. 322 * @return Whether the specified {@link Rect} is visible on the screen. 323 */ 324 private boolean intersectVisibleToUser(Rect localRect) { 325 // Missing or empty bounds mean this view is not visible. 326 if ((localRect == null) || localRect.isEmpty()) { 327 return false; 328 } 329 330 // Attached to invisible window means this view is not visible. 331 if (mParentView.getWindowVisibility() != View.VISIBLE) { 332 return false; 333 } 334 335 // An invisible predecessor or one with alpha zero means 336 // that this view is not visible to the user. 337 Object current = this; 338 while (current instanceof View) { 339 final View view = (View) current; 340 // We have attach info so this view is attached and there is no 341 // need to check whether we reach to ViewRootImpl on the way up. 342 if ((view.getAlpha() <= 0) || (view.getVisibility() != View.VISIBLE)) { 343 return false; 344 } 345 current = view.getParent(); 346 } 347 348 // If no portion of the parent is visible, this view is not visible. 349 if (!mParentView.getLocalVisibleRect(mTempVisibleRect)) { 350 return false; 351 } 352 353 // Check if the view intersects the visible portion of the parent. 354 return localRect.intersect(mTempVisibleRect); 355 } 356 357 public AccessibilityDelegateCompat getAccessibilityDelegate() { 358 return mDelegate; 359 } 360 361 private final AccessibilityDelegateCompat mDelegate = new AccessibilityDelegateCompat() { 362 @Override 363 public void onInitializeAccessibilityEvent(View view, AccessibilityEvent event) { 364 super.onInitializeAccessibilityEvent(view, event); 365 event.setClassName(view.getClass().getName()); 366 } 367 368 @Override 369 public void onInitializeAccessibilityNodeInfo(View view, AccessibilityNodeInfoCompat info) { 370 super.onInitializeAccessibilityNodeInfo(view, info); 371 info.setClassName(view.getClass().getName()); 372 } 373 374 @Override 375 public AccessibilityNodeProviderCompat getAccessibilityNodeProvider(View host) { 376 return TouchExplorationHelper.this; 377 } 378 }; 379 380 /** 381 * Performs an accessibility action on the specified item. See 382 * {@link AccessibilityNodeInfoCompat#performAction(int, Bundle)}. 383 * <p> 384 * The helper class automatically handles focus management resulting from 385 * {@link AccessibilityNodeInfoCompat#ACTION_ACCESSIBILITY_FOCUS} and 386 * {@link AccessibilityNodeInfoCompat#ACTION_CLEAR_ACCESSIBILITY_FOCUS}, so 387 * typically a developer only needs to handle actions added manually in the 388 * {{@link #populateNodeForItem(Object, AccessibilityNodeInfoCompat)} 389 * method. 390 * </p> 391 * 392 * @param item The item on which to perform the action. 393 * @param action The accessibility action to perform. 394 * @param arguments Arguments for the action, or optionally {@code null}. 395 * @return {@code true} if the action was performed successfully. 396 */ 397 protected abstract boolean performActionForItem(T item, int action, Bundle arguments); 398 399 /** 400 * Populates an event with information about the specified item. 401 * <p> 402 * At a minimum, a developer must populate the event text by doing one of 403 * the following: 404 * <ul> 405 * <li>appending text to {@link AccessibilityEvent#getText()}</li> 406 * <li>populating a description with 407 * {@link AccessibilityEvent#setContentDescription(CharSequence)}</li> 408 * </ul> 409 * </p> 410 * 411 * @param item The item for which to populate the event. 412 * @param event The event to populate. 413 */ 414 protected abstract void populateEventForItem(T item, AccessibilityEvent event); 415 416 /** 417 * Populates a node with information about the specified item. 418 * <p> 419 * At a minimum, a developer must: 420 * <ul> 421 * <li>populate the event text using 422 * {@link AccessibilityNodeInfoCompat#setText(CharSequence)} or 423 * {@link AccessibilityNodeInfoCompat#setContentDescription(CharSequence)} 424 * </li> 425 * <li>set the item's parent-relative bounds using 426 * {@link AccessibilityNodeInfoCompat#setBoundsInParent(Rect)} 427 * </ul> 428 * 429 * @param item The item for which to populate the node. 430 * @param node The node to populate. 431 */ 432 protected abstract void populateNodeForItem(T item, AccessibilityNodeInfoCompat node); 433 434 /** 435 * Populates a list with the parent view's visible items. 436 * <p> 437 * The result of this method is cached until the developer calls 438 * {@link #invalidateParent()}. 439 * </p> 440 * 441 * @param items The list to populate with visible items. 442 */ 443 protected abstract void getVisibleItems(List<T> items); 444 445 /** 446 * Returns the item under the specified parent-relative coordinates. 447 * 448 * @param x The parent-relative x coordinate. 449 * @param y The parent-relative y coordinate. 450 * @return The item under coordinates (x,y). 451 */ 452 protected abstract T getItemAt(float x, float y); 453 454 /** 455 * Returns the unique identifier for an item. If the specified item does not 456 * exist, returns {@link #INVALID_ID}. 457 * <p> 458 * This result of this method must be consistent with 459 * {@link #getItemForId(int)}. 460 * </p> 461 * 462 * @param item The item whose identifier to return. 463 * @return A unique identifier, or {@link #INVALID_ID}. 464 */ 465 protected abstract int getIdForItem(T item); 466 467 /** 468 * Returns the item for a unique identifier. If the specified item does not 469 * exist, returns {@code null}. 470 * 471 * @param id The identifier for the item to return. 472 * @return An item, or {@code null}. 473 */ 474 protected abstract T getItemForId(int id); 475 } 476