Replace AccessibilityDelegateCompat with ViewCompat.addAccessibilityAction for link actions

The previous AccessibilityDelegateCompat approach had two fatal flaws:
1. The delegate was only set when isTouchExplorationEnabled() returned
   true AT BIND TIME — a timing bug that caused TalkBack to see no
   actions if the view was bound before TalkBack fully initialised.
2. Even when set, the delegate/custom-action-ID approach did not
   reliably produce an "Actions" submenu in the TalkBack local context
   menu on Android 14+.

Replace the entire mechanism with ViewCompat.addAccessibilityAction(),
the modern API (androidx.core 1.5.0+) that TalkBack always surfaces
under "Actions". Actions are registered unconditionally at bind time;
stale actions from recycled views are removed via
ViewCompat.removeAccessibilityAction() before re-registration.

Delete the now-unused LinkAccessibilityDelegate class.

Agent-Logs-Url: https://github.com/ArcaneChat/android/sessions/95854760-c8e5-43d8-b7b4-55879a7c5dea

Co-authored-by: adbenitez <24558636+adbenitez@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-05-01 11:22:20 +00:00
committed by GitHub
parent dfa80abd13
commit 8217d3fc22
2 changed files with 35 additions and 134 deletions
@@ -23,6 +23,7 @@ import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.text.Spannable;
import android.text.Spanned;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
@@ -66,8 +67,8 @@ import org.thoughtcrime.securesms.mms.StickerSlide;
import org.thoughtcrime.securesms.mms.VcardSlide;
import org.thoughtcrime.securesms.reactions.ReactionsConversationView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.LinkAccessibilityDelegate;
import org.thoughtcrime.securesms.util.Linkifier;
import org.thoughtcrime.securesms.util.LongClickCopySpan;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.thoughtcrime.securesms.util.MarkdownUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
@@ -75,6 +76,7 @@ import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.Stub;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@@ -127,7 +129,9 @@ public class ConversationItem extends BaseConversationItem
private Stub<VcardView> vcardViewStub;
private Stub<CallItemView> callViewStub;
private @Nullable EventListener eventListener;
private @Nullable LinkAccessibilityDelegate linkAccessibilityDelegate;
// IDs of accessibility actions registered via ViewCompat.addAccessibilityAction, kept so they
// can be removed on rebind (RecyclerView reuses views for different messages).
private final List<Integer> linkActionIds = new ArrayList<>();
private int measureCalls;
@@ -416,6 +420,13 @@ public class ConversationItem extends BaseConversationItem
bodyText.setClickable(false);
bodyText.setFocusable(false);
// Remove any link actions registered for the previous message binding.
// RecyclerView reuses views, so stale actions from a previous item must be cleared.
for (int id : linkActionIds) {
ViewCompat.removeAccessibilityAction(this, id);
}
linkActionIds.clear();
String subject = messageRecord.getSubject();
String text = messageRecord.getText();
@@ -423,7 +434,6 @@ public class ConversationItem extends BaseConversationItem
if (messageRecord.getType() == DcMsg.DC_MSG_CALL || text.isEmpty()) {
bodyText.setVisibility(View.GONE);
ViewCompat.setAccessibilityDelegate(this, null);
}
else {
Spannable spannable = (Spannable) MarkdownUtil.toMarkdown(context, text);
@@ -433,25 +443,29 @@ public class ConversationItem extends BaseConversationItem
bodyText.setText(spannable);
bodyText.setVisibility(View.VISIBLE);
// Set accessibility delegate on THIS view (ConversationItem) for TalkBack to expose links as
// custom actions. The delegate must be on the parent because bodyText has
// importantForAccessibility="no" in the XML layout, so TalkBack focuses the parent.
// ViewCompat.setAccessibilityDelegate is used (not View.setAccessibilityDelegate) because it
// uses AccessibilityDelegateCompat, whose onInitializeAccessibilityNodeInfo callback receives
// a properly-backed AccessibilityNodeInfoCompat so that addAction() reliably surfaces custom
// actions in the TalkBack context menu on all Android versions.
// Note: During batch selection mode (batchSelected.isEmpty() == false), links are not
// linkified (see above), so there's no need to set the accessibility delegate.
// This also ensures that accessibility focus doesn't interfere with batch selection UI.
if (Util.isTouchExplorationEnabled(context) && batchSelected.isEmpty()) {
if (linkAccessibilityDelegate == null) {
linkAccessibilityDelegate = new LinkAccessibilityDelegate(context, bodyText);
} else {
linkAccessibilityDelegate.setBodyText(bodyText);
// Register a TalkBack "Actions" entry for each link in the message.
// ViewCompat.addAccessibilityAction is used instead of an AccessibilityDelegateCompat because
// it is the modern, reliable API (available since androidx.core 1.5.0) that TalkBack always
// surfaces under the "Actions" submenu of the local context menu (L-gesture).
// The actions are registered unconditionally — no isTouchExplorationEnabled() guard — because
// the registration is cheap and the guard was a source of timing bugs at bind time.
// Links are only present when not in batch-selection mode (Linkifier is not called in that
// branch above), so no actions are added in batch-selection mode.
if (spannable instanceof Spanned) {
Spanned spanned = (Spanned) spannable;
final TextView tv = bodyText;
for (LongClickCopySpan span : spanned.getSpans(0, spanned.length(), LongClickCopySpan.class)) {
int start = spanned.getSpanStart(span);
int end = spanned.getSpanEnd(span);
if (start >= 0 && end > start && end <= spanned.length()) {
String linkText = spanned.subSequence(start, end).toString();
String label = context.getString(R.string.accessibility_link_action, linkText);
linkActionIds.add(ViewCompat.addAccessibilityAction(this, label, (v, args) -> {
span.onClick(tv);
return true;
}));
}
}
ViewCompat.setAccessibilityDelegate(this, linkAccessibilityDelegate);
} else {
ViewCompat.setAccessibilityDelegate(this, null);
}
}
@@ -1,113 +0,0 @@
package org.thoughtcrime.securesms.util;
import android.content.Context;
import android.os.Bundle;
import android.text.Spanned;
import android.view.View;
import android.widget.TextView;
import androidx.core.view.accessibility.AccessibilityDelegateCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import org.thoughtcrime.securesms.R;
import java.util.HashMap;
import java.util.Map;
/**
* AccessibilityDelegateCompat attached to the ConversationItem (parent view) so that TalkBack
* can discover the links inside the child bodyText and expose them as custom actions.
*
* The bodyText TextView is marked importantForAccessibility="no" in the XML layout, which
* means TalkBack focuses the parent ConversationItem instead. The delegate therefore lives
* on the parent but reads spans from the supplied bodyText reference.
*
* Using AccessibilityDelegateCompat (set via ViewCompat.setAccessibilityDelegate) instead of
* the raw View.AccessibilityDelegate is important: the compat version's
* onInitializeAccessibilityNodeInfo callback receives a properly-backed AccessibilityNodeInfoCompat
* object, so addAction() reliably surfaces custom actions in the TalkBack context menu.
*/
public class LinkAccessibilityDelegate extends AccessibilityDelegateCompat {
// Base ID for custom link actions. Using a high number (0x00FF0000, which is 16,711,680 in decimal)
// to avoid conflicts with Android's standard accessibility action IDs, which are typically small integers
// (e.g., ACTION_CLICK = 16, ACTION_LONG_CLICK = 32, etc.) or specific bit flags.
// This range is safe for custom actions as per Android accessibility guidelines.
private static final int LINK_ACTION_BASE_ID = 0x00FF0000;
// Map to store span references for each action ID
private final Map<Integer, LongClickCopySpan> actionSpanMap = new HashMap<>();
private final Context context;
// The child TextView whose Spanned text contains the LongClickCopySpan links.
private TextView bodyText;
public LinkAccessibilityDelegate(Context context, TextView bodyText) {
this.context = context;
this.bodyText = bodyText;
}
public void setBodyText(TextView bodyText) {
this.bodyText = bodyText;
}
@Override
public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
super.onInitializeAccessibilityNodeInfo(host, info);
if (bodyText == null) {
return;
}
CharSequence text = bodyText.getText();
if (!(text instanceof Spanned)) {
return;
}
Spanned spanned = (Spanned) text;
LongClickCopySpan[] spans = spanned.getSpans(0, spanned.length(), LongClickCopySpan.class);
// Clear and rebuild mappings each time. This is necessary because:
// 1. TextView content may have changed since the last call
// 2. The same delegate instance may be reused for different text content
// 3. This method is called infrequently enough that performance is not a concern
actionSpanMap.clear();
// Add a custom action for each link
for (int i = 0; i < spans.length; i++) {
final LongClickCopySpan span = spans[i];
int start = spanned.getSpanStart(span);
int end = spanned.getSpanEnd(span);
if (start >= 0 && end > start && end <= spanned.length()) {
String linkText = spanned.subSequence(start, end).toString();
String label = context.getString(R.string.accessibility_link_action, linkText);
// Create a unique action ID for each link
final int actionId = LINK_ACTION_BASE_ID + i;
// Store the mapping
actionSpanMap.put(actionId, span);
info.addAction(new AccessibilityNodeInfoCompat.AccessibilityActionCompat(actionId, label));
}
}
}
@Override
public boolean performAccessibilityAction(View host, int action, Bundle args) {
// Check if this is one of our custom link actions.
// Pass bodyText as the widget so LongClickCopySpan.onClick() receives the correct view
// (it calls widget.getContext() to reach the hosting Activity).
if (actionSpanMap.containsKey(action)) {
LongClickCopySpan span = actionSpanMap.get(action);
if (span != null && bodyText != null) {
span.onClick(bodyText);
return true;
}
}
return super.performAccessibilityAction(host, action, args);
}
}