From 8217d3fc22dd01d3a7d4af577efbf8d6455491c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 11:22:20 +0000 Subject: [PATCH] Replace AccessibilityDelegateCompat with ViewCompat.addAccessibilityAction for link actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../securesms/ConversationItem.java | 56 +++++---- .../util/LinkAccessibilityDelegate.java | 113 ------------------ 2 files changed, 35 insertions(+), 134 deletions(-) delete mode 100644 src/main/java/org/thoughtcrime/securesms/util/LinkAccessibilityDelegate.java diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index 424a2609b..93b632070 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -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 vcardViewStub; private Stub 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 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); } } diff --git a/src/main/java/org/thoughtcrime/securesms/util/LinkAccessibilityDelegate.java b/src/main/java/org/thoughtcrime/securesms/util/LinkAccessibilityDelegate.java deleted file mode 100644 index f0b120ccd..000000000 --- a/src/main/java/org/thoughtcrime/securesms/util/LinkAccessibilityDelegate.java +++ /dev/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 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); - } -}