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