1 /* 2 * Copyright (C) 2016 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of 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, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.setupwizardlib.util; 18 19 import android.graphics.Rect; 20 import android.os.Build; 21 import android.os.Bundle; 22 import android.support.v4.view.AccessibilityDelegateCompat; 23 import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; 24 import android.support.v4.view.accessibility.AccessibilityNodeProviderCompat; 25 import android.support.v4.widget.ExploreByTouchHelper; 26 import android.text.Layout; 27 import android.text.Spanned; 28 import android.text.style.ClickableSpan; 29 import android.util.Log; 30 import android.view.MotionEvent; 31 import android.view.View; 32 import android.view.ViewGroup; 33 import android.view.accessibility.AccessibilityEvent; 34 import android.widget.TextView; 35 36 import java.util.List; 37 38 /** 39 * An accessibility delegate that allows {@link android.text.style.ClickableSpan} to be focused and 40 * clicked by accessibility services. 41 * <p> 42 * <strong>Note: </strong> From Android O on, there is native support for ClickableSpan 43 * accessibility, so this class is not needed (and indeed has no effect.) 44 * </p> 45 * 46 * <p />Sample usage: 47 * <pre> 48 * LinkAccessibilityHelper mAccessibilityHelper; 49 * 50 * private void init() { 51 * mAccessibilityHelper = new LinkAccessibilityHelper(myTextView); 52 * ViewCompat.setAccessibilityDelegate(myTextView, mLinkHelper); 53 * } 54 * 55 * {@literal @}Override 56 * protected boolean dispatchHoverEvent({@literal @}NonNull MotionEvent event) { 57 * if (mAccessibilityHelper != null && mAccessibilityHelper.dispatchHoverEvent(event)) { 58 * return true; 59 * } 60 * return super.dispatchHoverEvent(event); 61 * } 62 * </pre> 63 * 64 * @see com.android.setupwizardlib.view.RichTextView 65 * @see android.support.v4.widget.ExploreByTouchHelper 66 */ 67 public class LinkAccessibilityHelper extends AccessibilityDelegateCompat { 68 69 private static final String TAG = "LinkAccessibilityHelper"; 70 71 private final TextView mView; 72 private final Rect mTempRect = new Rect(); 73 private final ExploreByTouchHelper mExploreByTouchHelper; 74 75 public LinkAccessibilityHelper(TextView view) { 76 if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) { 77 // Pre-O, we essentially extend ExploreByTouchHelper to expose a virtual view hierarchy 78 mExploreByTouchHelper = new ExploreByTouchHelper(view) { 79 @Override 80 protected int getVirtualViewAt(float x, float y) { 81 return LinkAccessibilityHelper.this.getVirtualViewAt(x, y); 82 } 83 84 @Override 85 protected void getVisibleVirtualViews(List<Integer> virtualViewIds) { 86 LinkAccessibilityHelper.this.getVisibleVirtualViews(virtualViewIds); 87 } 88 89 @Override 90 protected void onPopulateEventForVirtualView(int virtualViewId, 91 AccessibilityEvent event) { 92 LinkAccessibilityHelper 93 .this.onPopulateEventForVirtualView(virtualViewId, event); 94 } 95 96 @Override 97 protected void onPopulateNodeForVirtualView(int virtualViewId, 98 AccessibilityNodeInfoCompat infoCompat) { 99 LinkAccessibilityHelper 100 .this.onPopulateNodeForVirtualView(virtualViewId, infoCompat); 101 102 } 103 104 @Override 105 protected boolean onPerformActionForVirtualView(int virtualViewId, int action, 106 Bundle arguments) { 107 return LinkAccessibilityHelper.this 108 .onPerformActionForVirtualView(virtualViewId, action, arguments); 109 } 110 }; 111 } else { 112 mExploreByTouchHelper = null; 113 } 114 mView = view; 115 } 116 117 @Override 118 public void sendAccessibilityEvent(View host, int eventType) { 119 if (mExploreByTouchHelper != null) { 120 mExploreByTouchHelper.sendAccessibilityEvent(host, eventType); 121 } else { 122 super.sendAccessibilityEvent(host, eventType); 123 } 124 } 125 126 @Override 127 public void sendAccessibilityEventUnchecked(View host, AccessibilityEvent event) { 128 if (mExploreByTouchHelper != null) { 129 mExploreByTouchHelper.sendAccessibilityEventUnchecked(host, event); 130 } else { 131 super.sendAccessibilityEventUnchecked(host, event); 132 } 133 } 134 135 @Override 136 public boolean dispatchPopulateAccessibilityEvent(View host, AccessibilityEvent event) { 137 return (mExploreByTouchHelper != null) 138 ? mExploreByTouchHelper.dispatchPopulateAccessibilityEvent(host, event) 139 : super.dispatchPopulateAccessibilityEvent(host, event); 140 } 141 142 @Override 143 public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) { 144 if (mExploreByTouchHelper != null) { 145 mExploreByTouchHelper.onPopulateAccessibilityEvent(host, event); 146 } else { 147 super.onPopulateAccessibilityEvent(host, event); 148 } 149 } 150 151 @Override 152 public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) { 153 if (mExploreByTouchHelper != null) { 154 mExploreByTouchHelper.onInitializeAccessibilityEvent(host, event); 155 } else { 156 super.onInitializeAccessibilityEvent(host, event); 157 } 158 } 159 160 @Override 161 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) { 162 if (mExploreByTouchHelper != null) { 163 mExploreByTouchHelper.onInitializeAccessibilityNodeInfo(host, info); 164 } else { 165 super.onInitializeAccessibilityNodeInfo(host, info); 166 } 167 } 168 169 @Override 170 public boolean onRequestSendAccessibilityEvent(ViewGroup host, View child, 171 AccessibilityEvent event) { 172 return (mExploreByTouchHelper != null) 173 ? mExploreByTouchHelper.onRequestSendAccessibilityEvent(host, child, event) 174 : super.onRequestSendAccessibilityEvent(host, child, event); 175 } 176 177 @Override 178 public AccessibilityNodeProviderCompat getAccessibilityNodeProvider(View host) { 179 return (mExploreByTouchHelper != null) 180 ? mExploreByTouchHelper.getAccessibilityNodeProvider(host) 181 : super.getAccessibilityNodeProvider(host); 182 } 183 184 @Override 185 public boolean performAccessibilityAction(View host, int action, Bundle args) { 186 return (mExploreByTouchHelper != null) 187 ? mExploreByTouchHelper.performAccessibilityAction(host, action, args) 188 : super.performAccessibilityAction(host, action, args); 189 } 190 191 /** 192 * Delegated to {@link ExploreByTouchHelper} 193 */ 194 public final boolean dispatchHoverEvent(MotionEvent event) { 195 return (mExploreByTouchHelper != null) ? mExploreByTouchHelper.dispatchHoverEvent(event) 196 : false; 197 } 198 199 protected int getVirtualViewAt(float x, float y) { 200 final CharSequence text = mView.getText(); 201 if (text instanceof Spanned) { 202 final Spanned spannedText = (Spanned) text; 203 final int offset = getOffsetForPosition(mView, x, y); 204 ClickableSpan[] linkSpans = spannedText.getSpans(offset, offset, ClickableSpan.class); 205 if (linkSpans.length == 1) { 206 ClickableSpan linkSpan = linkSpans[0]; 207 return spannedText.getSpanStart(linkSpan); 208 } 209 } 210 return ExploreByTouchHelper.INVALID_ID; 211 } 212 213 protected void getVisibleVirtualViews(List<Integer> virtualViewIds) { 214 final CharSequence text = mView.getText(); 215 if (text instanceof Spanned) { 216 final Spanned spannedText = (Spanned) text; 217 ClickableSpan[] linkSpans = spannedText.getSpans(0, spannedText.length(), 218 ClickableSpan.class); 219 for (ClickableSpan span : linkSpans) { 220 virtualViewIds.add(spannedText.getSpanStart(span)); 221 } 222 } 223 } 224 225 protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) { 226 final ClickableSpan span = getSpanForOffset(virtualViewId); 227 if (span != null) { 228 event.setContentDescription(getTextForSpan(span)); 229 } else { 230 Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId); 231 event.setContentDescription(mView.getText()); 232 } 233 } 234 235 protected void onPopulateNodeForVirtualView(int virtualViewId, 236 AccessibilityNodeInfoCompat info) { 237 final ClickableSpan span = getSpanForOffset(virtualViewId); 238 if (span != null) { 239 info.setContentDescription(getTextForSpan(span)); 240 } else { 241 Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId); 242 info.setContentDescription(mView.getText()); 243 } 244 info.setFocusable(true); 245 info.setClickable(true); 246 getBoundsForSpan(span, mTempRect); 247 if (mTempRect.isEmpty()) { 248 Log.e(TAG, "LinkSpan bounds is empty for: " + virtualViewId); 249 mTempRect.set(0, 0, 1, 1); 250 } 251 info.setBoundsInParent(mTempRect); 252 info.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK); 253 } 254 255 protected boolean onPerformActionForVirtualView(int virtualViewId, int action, 256 Bundle arguments) { 257 if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) { 258 ClickableSpan span = getSpanForOffset(virtualViewId); 259 if (span != null) { 260 span.onClick(mView); 261 return true; 262 } else { 263 Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId); 264 } 265 } 266 return false; 267 } 268 269 private ClickableSpan getSpanForOffset(int offset) { 270 CharSequence text = mView.getText(); 271 if (text instanceof Spanned) { 272 Spanned spannedText = (Spanned) text; 273 ClickableSpan[] spans = spannedText.getSpans(offset, offset, ClickableSpan.class); 274 if (spans.length == 1) { 275 return spans[0]; 276 } 277 } 278 return null; 279 } 280 281 private CharSequence getTextForSpan(ClickableSpan span) { 282 CharSequence text = mView.getText(); 283 if (text instanceof Spanned) { 284 Spanned spannedText = (Spanned) text; 285 return spannedText.subSequence(spannedText.getSpanStart(span), 286 spannedText.getSpanEnd(span)); 287 } 288 return text; 289 } 290 291 // Find the bounds of a span. If it spans multiple lines, it will only return the bounds for the 292 // section on the first line. 293 private Rect getBoundsForSpan(ClickableSpan span, Rect outRect) { 294 CharSequence text = mView.getText(); 295 outRect.setEmpty(); 296 if (text instanceof Spanned) { 297 final Layout layout = mView.getLayout(); 298 if (layout != null) { 299 Spanned spannedText = (Spanned) text; 300 final int spanStart = spannedText.getSpanStart(span); 301 final int spanEnd = spannedText.getSpanEnd(span); 302 final float xStart = layout.getPrimaryHorizontal(spanStart); 303 final float xEnd = layout.getPrimaryHorizontal(spanEnd); 304 final int lineStart = layout.getLineForOffset(spanStart); 305 final int lineEnd = layout.getLineForOffset(spanEnd); 306 layout.getLineBounds(lineStart, outRect); 307 if (lineEnd == lineStart) { 308 // If the span is on a single line, adjust both the left and right bounds 309 // so outrect is exactly bounding the span. 310 outRect.left = (int) Math.min(xStart, xEnd); 311 outRect.right = (int) Math.max(xStart, xEnd); 312 } else { 313 // If the span wraps across multiple lines, only use the first line (as returned 314 // by layout.getLineBounds above), and adjust the "start" of outrect to where 315 // the span starts, leaving the "end" of outrect at the end of the line. 316 // ("start" being left for LTR, and right for RTL) 317 if (layout.getParagraphDirection(lineStart) == Layout.DIR_RIGHT_TO_LEFT) { 318 outRect.right = (int) xStart; 319 } else { 320 outRect.left = (int) xStart; 321 } 322 } 323 324 // Offset for padding 325 outRect.offset(mView.getTotalPaddingLeft(), mView.getTotalPaddingTop()); 326 } 327 } 328 return outRect; 329 } 330 331 // Compat implementation of TextView#getOffsetForPosition(). 332 333 private static int getOffsetForPosition(TextView view, float x, float y) { 334 if (view.getLayout() == null) return -1; 335 final int line = getLineAtCoordinate(view, y); 336 return getOffsetAtCoordinate(view, line, x); 337 } 338 339 private static float convertToLocalHorizontalCoordinate(TextView view, float x) { 340 x -= view.getTotalPaddingLeft(); 341 // Clamp the position to inside of the view. 342 x = Math.max(0.0f, x); 343 x = Math.min(view.getWidth() - view.getTotalPaddingRight() - 1, x); 344 x += view.getScrollX(); 345 return x; 346 } 347 348 private static int getLineAtCoordinate(TextView view, float y) { 349 y -= view.getTotalPaddingTop(); 350 // Clamp the position to inside of the view. 351 y = Math.max(0.0f, y); 352 y = Math.min(view.getHeight() - view.getTotalPaddingBottom() - 1, y); 353 y += view.getScrollY(); 354 return view.getLayout().getLineForVertical((int) y); 355 } 356 357 private static int getOffsetAtCoordinate(TextView view, int line, float x) { 358 x = convertToLocalHorizontalCoordinate(view, x); 359 return view.getLayout().getOffsetForHorizontal(line, x); 360 } 361 } 362