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