mirror of
https://github.com/ArcaneChat/android.git
synced 2026-07-03 14:05:24 +02:00
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:
committed by
GitHub
parent
dfa80abd13
commit
8217d3fc22
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user