Skip to content

fix(android): a long-press on a link no longer also opens selection or block menus - #1011

Merged
hryhoriiK97 merged 2 commits into
software-mansion:mainfrom
voida6:fix/android-link-long-press-race
Oct 18, 2026
Merged

hryhoriiK97 merged 2 commits into
software-mansion:mainfrom
voida6:fix/android-link-long-press-race

Conversation

@voida6

@voida6 voida6 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What/Why?

On Android, a long-press on a link sometimes opens two things at once: the link long-press (onLinkLongPress, which apps use for a link menu) and the view's own long-press. In a paragraph that second one is text selection with its toolbar; in a table cell it is the table's Copy / Copy as Markdown menu.

It is a race between two callbacks with the same timeout:

  1. TextView.onTouchEvent calls View.onTouchEvent first, which posts the view's long-press callback (ViewConfiguration.getLongPressTimeout()).
  2. Only then does the movement method see ACTION_DOWN. LinkLongPressMovementMethod.scheduleLongPress posts the link runnable with the same timeout.
  3. The runnable already calls widget.cancelLongPress(), but that only helps when it runs first. When the view's callback runs first, both fire.

I logged both callbacks on a Galaxy S25 Ultra (Android 16) in an app using react-native-enriched-markdown. Before this change, the view's performLongClick ran 1-11 ms before the link runnable in 2 of 3 long-presses on a table link and in every long-press on a paragraph link. Those were the presses that opened two menus.

The fix calls widget.cancelLongPress() in scheduleLongPress, which only runs for an ACTION_DOWN on a link, so the view's long-press never fires for that gesture. cancelLongPress() also removes the pending tap callback that posts the long-press later inside scrolling containers, and the framework only re-arms a long-press for ambiguous or deep presses while one is still pending. A long-press on plain text is unchanged. The same 4 lines go into both Android implementations (react-native-enriched-markdown and enriched-markdown-android/ui).

Testing

  • New LinkLongPressInteractionTest (Robolectric, enriched-markdown-android/ui) dispatches a real long-press through an attached EnrichedMarkdownInternalText with a long-click listener. On main, a long-press on a link fires both the link long-press and the view's long-click and the test fails; with the fix only the link long-press fires. A second test checks that a long-press on plain text still reaches the view. ./gradlew :ui:testDebugUnitTest for the new test and :ui:ktlintCheck pass.
  • I did not add the test to the React Native package's unit tests; the change there is the same 4 lines. I checked the React Native implementation on device instead: a release build of an app on react-native-enriched-markdown with this patch, on a Galaxy S25 Ultra (Android 16, One UI). Same table and paragraph links as before.
Before After
Table cell link, repeated long-presses link menu + table menu on 2 of 3 link menu only, 5 of 5
Table cell links, other rows link menu only (4 of 4 in this run) link menu only, 7 of 7
Paragraph link link menu + text selection, 3 of 3 link menu only, 2 of 2
Plain table text table menu table menu
Plain paragraph word text selection text selection
Before After

Recordings (white dots are touches). Before:

before-double-menus.mp4

After:

after-single-menu.mp4

PR Checklist

  • Code compiles and runs on iOS
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing
  • Required E2E tests have been added (if applicable)

iOS is unchanged. I verified in a downstream app (T3 Code) instead of the example app, and did not run the Maestro suite. No docs change: link long-press behavior is unchanged except that it no longer collides with the view's own long-press.

…r block menus

TextView posts its own long-press callback in View.onTouchEvent before
LinkLongPressMovementMethod sees ACTION_DOWN, and the link long-press
runnable uses the same timeout. The runnable cancelled the view's
long-press, but only when it happened to run first. When the view's
callback ran first, a long-press on a link opened the link long-press
and also text selection (paragraphs) or the block context menu (table
cells).

Cancel the view's long-press when the press lands on a link, before
scheduling the link long-press. A long-press on plain text is unchanged.
@voida6

voida6 commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

Hi @jnowakow, could you or another maintainer approve the CI workflow runs for this PR and #1012? They are waiting for approval because both come from a fork. Thanks!

@hryhoriiK97

Copy link
Copy Markdown
Collaborator

Thanks for the fix and the detailed write-up! I reproduced the race on an Android emulator: the link long-press sometimes also opened text selection or the table menu without the patch, and only the link fired with it. I pushed a small cleanup on top:

  • shortened the comment above widget.cancelLongPress() to one line in both copies
  • removed LinkLongPressInteractionTest; for a one-line fix we'd rather not keep the Robolectric timing setup around

@voida6

voida6 commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks!

@hryhoriiK97
hryhoriiK97 merged commit 29b48a2 into software-mansion:main Oct 18, 2026
19 checks passed
@voida6

voida6 commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants