Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix issue where state updating text to remove link would break TalkBack #49379

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -3929,6 +3929,7 @@ public abstract class com/facebook/react/uimanager/BaseViewManager : com/faceboo
public fun setTranslateY (Landroid/view/View;F)V
public fun setViewState (Landroid/view/View;Lcom/facebook/react/bridge/ReadableMap;)V
public fun setZIndex (Landroid/view/View;F)V
protected fun updateViewAccessibility (Landroid/view/View;)V
}

public abstract class com/facebook/react/uimanager/BaseViewManagerDelegate : com/facebook/react/uimanager/ViewManagerDelegate {
Expand Down Expand Up @@ -4222,7 +4223,7 @@ public class com/facebook/react/uimanager/ReactAccessibilityDelegate : androidx/
public fun <init> (Landroid/view/View;ZI)V
public static fun createNodeInfoFromView (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;
public fun getAccessibilityNodeProvider (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeProviderCompat;
protected fun getFirstSpan (IILjava/lang/Class;)Ljava/lang/Object;
protected fun getHostView ()Landroid/view/View;
public static fun getTalkbackDescription (Landroid/view/View;Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;)Ljava/lang/CharSequence;
protected fun getVirtualViewAt (FF)I
protected fun getVisibleVirtualViews (Ljava/util/List;)V
Expand All @@ -4240,13 +4241,7 @@ public class com/facebook/react/uimanager/ReactAccessibilityDelegate : androidx/
public static fun resetDelegate (Landroid/view/View;ZI)V
public static fun setDelegate (Landroid/view/View;ZI)V
public static fun setRole (Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityRole;Landroid/content/Context;)V
}

public class com/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks {
public fun <init> ([Landroid/text/style/ClickableSpan;Landroid/text/Spannable;)V
public fun getLinkById (I)Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
public fun getLinkBySpanPos (II)Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
public fun size ()I
public fun superGetAccessibilityNodeProvider (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeProviderCompat;
}

public final class com/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityRole : java/lang/Enum {
Expand Down Expand Up @@ -7087,6 +7082,36 @@ public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/wi
protected fun verifyDrawable (Landroid/graphics/drawable/Drawable;)Z
}

public final class com/facebook/react/views/text/ReactTextViewAccessibilityDelegate : com/facebook/react/uimanager/ReactAccessibilityDelegate {
public static final field Companion Lcom/facebook/react/views/text/ReactTextViewAccessibilityDelegate$Companion;
public fun <init> (Landroid/view/View;ZI)V
public fun getAccessibilityNodeProvider (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeProviderCompat;
}

public final class com/facebook/react/views/text/ReactTextViewAccessibilityDelegate$AccessibilityLinks {
public fun <init> ([Landroid/text/style/ClickableSpan;Landroid/text/Spannable;)V
public final fun getLinkById (I)Lcom/facebook/react/views/text/ReactTextViewAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
public final fun getLinkBySpanPos (II)Lcom/facebook/react/views/text/ReactTextViewAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
public final fun size ()I
}

public final class com/facebook/react/views/text/ReactTextViewAccessibilityDelegate$AccessibilityLinks$AccessibleLink {
public fun <init> ()V
public final fun getDescription ()Ljava/lang/String;
public final fun getEnd ()I
public final fun getId ()I
public final fun getStart ()I
public final fun setDescription (Ljava/lang/String;)V
public final fun setEnd (I)V
public final fun setId (I)V
public final fun setStart (I)V
}

public final class com/facebook/react/views/text/ReactTextViewAccessibilityDelegate$Companion {
public final fun resetDelegate (Landroid/view/View;ZI)V
public final fun setDelegate (Landroid/view/View;ZI)V
}

public class com/facebook/react/views/text/ReactTextViewManager : com/facebook/react/views/text/ReactTextAnchorViewManager, com/facebook/react/uimanager/IViewManagerWithChildren {
protected field mReactTextViewManagerCallback Lcom/facebook/react/views/text/ReactTextViewManagerCallback;
public fun <init> ()V
Expand All @@ -7112,6 +7137,8 @@ public class com/facebook/react/views/text/ReactTextViewManager : com/facebook/r
public fun updateExtraData (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/Object;)V
public synthetic fun updateState (Landroid/view/View;Lcom/facebook/react/uimanager/ReactStylesDiffMap;Lcom/facebook/react/uimanager/StateWrapper;)Ljava/lang/Object;
public fun updateState (Lcom/facebook/react/views/text/ReactTextView;Lcom/facebook/react/uimanager/ReactStylesDiffMap;Lcom/facebook/react/uimanager/StateWrapper;)Ljava/lang/Object;
protected synthetic fun updateViewAccessibility (Landroid/view/View;)V
protected fun updateViewAccessibility (Lcom/facebook/react/views/text/ReactTextView;)V
}

public abstract interface class com/facebook/react/views/text/ReactTextViewManagerCallback {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ private static float sanitizeFloatPropertyValue(float value) {
throw new IllegalStateException("Invalid float property value: " + value);
}

private void updateViewAccessibility(@NonNull T view) {
protected void updateViewAccessibility(@NonNull T view) {
ReactAccessibilityDelegate.setDelegate(
view, view.isFocusable(), view.getImportantForAccessibility());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,15 @@
package com.facebook.react.uimanager;

import android.content.Context;
import android.graphics.Paint;
import android.graphics.Rect;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.text.Layout;
import android.text.Spannable;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.ClickableSpan;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
Expand All @@ -44,12 +37,10 @@
import com.facebook.react.bridge.ReadableType;
import com.facebook.react.bridge.UIManager;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole;
import com.facebook.react.uimanager.common.ViewUtil;
import com.facebook.react.uimanager.events.Event;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.util.ReactFindViewUtil;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

Expand Down Expand Up @@ -79,7 +70,6 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
}

private final View mView;
private final AccessibilityLinks mAccessibilityLinks;

private Handler mHandler;

Expand Down Expand Up @@ -405,7 +395,11 @@ public void handleMessage(Message msg) {
// announcement coalescing.
mView.setFocusable(originalFocus);
ViewCompat.setImportantForAccessibility(mView, originalImportantForAccessibility);
mAccessibilityLinks = (AccessibilityLinks) mView.getTag(R.id.accessibility_links);
}

// The View this delegate is attached to
protected View getHostView() {
return mView;
}

@Nullable View mAccessibilityLabelledBy;
Expand Down Expand Up @@ -716,143 +710,17 @@ public static void resetDelegate(

@Override
protected int getVirtualViewAt(float x, float y) {
if (mAccessibilityLinks == null
|| mAccessibilityLinks.size() == 0
|| !(mView instanceof TextView)) {
return INVALID_ID;
}

TextView textView = (TextView) mView;
if (!(textView.getText() instanceof Spanned)) {
return INVALID_ID;
}

Layout layout = textView.getLayout();
if (layout == null) {
return INVALID_ID;
}

x -= textView.getTotalPaddingLeft();
y -= textView.getTotalPaddingTop();
x += textView.getScrollX();
y += textView.getScrollY();

int line = layout.getLineForVertical((int) y);
int charOffset = layout.getOffsetForHorizontal(line, x);

ClickableSpan clickableSpan = getFirstSpan(charOffset, charOffset, ClickableSpan.class);
if (clickableSpan == null) {
return INVALID_ID;
}

Spanned spanned = (Spanned) textView.getText();
int start = spanned.getSpanStart(clickableSpan);
int end = spanned.getSpanEnd(clickableSpan);

final AccessibilityLinks.AccessibleLink link = mAccessibilityLinks.getLinkBySpanPos(start, end);
return link != null ? link.id : INVALID_ID;
return INVALID_ID;
}

@Override
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
if (mAccessibilityLinks == null) {
return;
}

for (int i = 0; i < mAccessibilityLinks.size(); i++) {
virtualViewIds.add(i);
}
}
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {}

@Override
protected void onPopulateNodeForVirtualView(
int virtualViewId, @NonNull AccessibilityNodeInfoCompat node) {
// If we get an invalid virtualViewId for some reason (which is known to happen in API 19 and
// below), return an "empty" node to prevent from crashing. This will never be presented to
// the user, as Talkback filters out nodes with no content to announce.
if (mAccessibilityLinks == null) {
node.setContentDescription("");
node.setBoundsInParent(new Rect(0, 0, 1, 1));
return;
}

final AccessibilityLinks.AccessibleLink accessibleTextSpan =
mAccessibilityLinks.getLinkById(virtualViewId);
if (accessibleTextSpan == null) {
node.setContentDescription("");
node.setBoundsInParent(new Rect(0, 0, 1, 1));
return;
}

// NOTE: The span may not actually have visible bounds within its parent,
// due to line limits, etc.
final Rect bounds = getBoundsInParent(accessibleTextSpan);
if (bounds == null) {
node.setContentDescription("");
node.setBoundsInParent(new Rect(0, 0, 1, 1));
return;
}

node.setContentDescription(accessibleTextSpan.description);
node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
node.setBoundsInParent(bounds);
node.setRoleDescription(mView.getResources().getString(R.string.link_description));
node.setClassName(AccessibilityRole.getValue(AccessibilityRole.BUTTON));
}

private Rect getBoundsInParent(AccessibilityLinks.AccessibleLink accessibleLink) {
// This view is not a text view, so return the entire views bounds.
if (!(mView instanceof TextView)) {
return new Rect(0, 0, mView.getWidth(), mView.getHeight());
}

TextView textView = (TextView) mView;
Layout textViewLayout = textView.getLayout();
if (textViewLayout == null) {
return new Rect(0, 0, textView.getWidth(), textView.getHeight());
}

double startOffset = accessibleLink.start;
double endOffset = accessibleLink.end;

// Ensure the link hasn't been ellipsized away; in such cases,
// getPrimaryHorizontal will crash (and the link isn't rendered anyway).
int startOffsetLineNumber = textViewLayout.getLineForOffset((int) startOffset);
int lineEndOffset = textViewLayout.getLineEnd(startOffsetLineNumber);
if (startOffset > lineEndOffset) {
return null;
}

Rect rootRect = new Rect();

double startXCoordinates = textViewLayout.getPrimaryHorizontal((int) startOffset);

final Paint paint = new Paint();
AbsoluteSizeSpan sizeSpan =
getFirstSpan(accessibleLink.start, accessibleLink.end, AbsoluteSizeSpan.class);
float textSize = sizeSpan != null ? sizeSpan.getSize() : textView.getTextSize();
paint.setTextSize(textSize);
int textWidth = (int) Math.ceil(paint.measureText(accessibleLink.description));

int endOffsetLineNumber = textViewLayout.getLineForOffset((int) endOffset);
boolean isMultiline = startOffsetLineNumber != endOffsetLineNumber;
textViewLayout.getLineBounds(startOffsetLineNumber, rootRect);

int verticalOffset = textView.getScrollY() + textView.getTotalPaddingTop();
rootRect.top += verticalOffset;
rootRect.bottom += verticalOffset;
rootRect.left += startXCoordinates + textView.getTotalPaddingLeft() - textView.getScrollX();

// The bounds for multi-line strings should *only* include the first line. This is because for
// API 25 and below, Talkback's click is triggered at the center point of these bounds, and if
// that center point is outside the spannable, it will click on something else. There is no
// harm in not outlining the wrapped part of the string, as the text for the whole string will
// be read regardless of the bounding box.
if (isMultiline) {
return new Rect(rootRect.left, rootRect.top, rootRect.right, rootRect.bottom);
}

return new Rect(rootRect.left, rootRect.top, rootRect.left + textWidth, rootRect.bottom);
node.setContentDescription("");
node.setBoundsInParent(new Rect(0, 0, 1, 1));
}

@Override
Expand All @@ -861,97 +729,17 @@ protected boolean onPerformActionForVirtualView(
return false;
}

protected @Nullable <T> T getFirstSpan(int start, int end, Class<T> classType) {
if (!(mView instanceof TextView) || !(((TextView) mView).getText() instanceof Spanned)) {
return null;
}

Spanned spanned = (Spanned) ((TextView) mView).getText();
T[] spans = spanned.getSpans(start, end, classType);
return spans.length > 0 ? spans[0] : null;
}

public static class AccessibilityLinks {
private final List<AccessibleLink> mLinks;

public AccessibilityLinks(ClickableSpan[] spans, Spannable text) {
ArrayList<AccessibleLink> links = new ArrayList<>();
for (int i = 0; i < spans.length; i++) {
ClickableSpan span = spans[i];
int start = text.getSpanStart(span);
int end = text.getSpanEnd(span);
// zero length spans, and out of range spans should not be included.
if (start == end || start < 0 || end < 0 || start > text.length() || end > text.length()) {
continue;
}

final AccessibleLink link = new AccessibleLink();
link.description = text.subSequence(start, end).toString();
link.start = start;
link.end = end;

// ID is the reverse of what is expected, since the ClickableSpans are returned in reverse
// order due to being added in reverse order. If we don't do this, focus will move to the
// last link first and move backwards.
//
// If this approach becomes unreliable, we should instead look at their start position and
// order them manually.
link.id = spans.length - 1 - i;
links.add(link);
}
mLinks = links;
}

@Nullable
public AccessibleLink getLinkById(int id) {
for (AccessibleLink link : mLinks) {
if (link.id == id) {
return link;
}
}

return null;
}

@Nullable
public AccessibleLink getLinkBySpanPos(int start, int end) {
for (AccessibleLink link : mLinks) {
if (link.start == start && link.end == end) {
return link;
}
}

return null;
}

public int size() {
return mLinks.size();
}

private static class AccessibleLink {
public String description;
public int start;
public int end;
public int id;
}
}

@Override
public @Nullable AccessibilityNodeProviderCompat getAccessibilityNodeProvider(View host) {
// Only set a NodeProvider if we have virtual views, otherwise just return null here so that
// we fall back to the View class's default behavior. If we don't do this, then Views with
// no virtual children will fall back to using ExploreByTouchHelper's onPopulateNodeForHost
// method to populate their AccessibilityNodeInfo, which defaults to doing nothing, so no
// AccessibilityNodeInfo will be created. Alternatively, we could override
// onPopulateNodeForHost instead, and have it create an AccessibilityNodeInfo for the host
// but this is what the default View class does by itself, so we may as well defer to it.
if (mAccessibilityLinks != null) {
return super.getAccessibilityNodeProvider(host);
}

return null;
}

// This exists so classes that extend this can properly call super's impl of this method while
// still being able to override it properly for this class
public @Nullable AccessibilityNodeProviderCompat superGetAccessibilityNodeProvider(View host) {
return super.getAccessibilityNodeProvider(host);
}

/**
* Determines if the supplied {@link View} and {@link AccessibilityNodeInfoCompat} has any
* children which are not independently accessibility focusable and also have a spoken
Expand Down
Loading
Loading