Skip to content

Fix external apps opening inside Signal's task - #14989

Open
theabhishekchandra wants to merge 2 commits into
signalapp:mainfrom
theabhishekchandra:fix/external-app-task-14946
Open

theabhishekchandra wants to merge 2 commits into
signalapp:mainfrom
theabhishekchandra:fix/external-app-task-14946

Conversation

@theabhishekchandra

@theabhishekchandra theabhishekchandra commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

First time contributor checklist

Contributor checklist

  • I am following the Code Style Guidelines
  • I have tested my contribution on these devices:
    • Vivo V2036, Android 13. The reporter's setup (X installed, Android 16) isn't directly reproducible here — no non-browser app on this device claims https links, and Chrome is immune — so I built a minimal stub registered for signal.org with launchMode="standard" and its own taskAffinity to stand in for X, and captured before/after against real builds of main with and without this patch.
  • My contribution is fully baked and ready to be merged as is
  • I ensure that all the open issues my contribution fixes are mentioned in the commit message of my first commit using the Fixes #1234 syntax

Description

Tapping a link handled by a non-browser app launches that app into Signal's task. Recents shows one card with Signal's icon and label whose contents are the other app, and the other app lands on top of Signal's back stack instead of beside it.

Same device, same tap on a link, only this patch differing. On the left the single Recents card is labelled Signal (Staging) but its contents are the other app; on the right the other app has its own card and Signal keeps its own. The red screen is the stub standing in for X.

Three paths hand a URL to another app, and none set FLAG_ACTIVITY_NEW_TASK:

  1. LinkActions.openUrl — via CommunicationActions.openBrowserLink (~71 call sites). It set the flag only when the context was not an Activity. That guard comes from 26cb17e ("Fix browser not opening in certain contexts"), added because Android requires the flag from a non-Activity context. It was never a decision about task placement — on the Activity path the flag was just missing.

  2. ConversationFragment.openLink — used by onUrlClicked and onLinkPreviewClicked. Builds its own intent and never goes through LinkActions. The Browser.EXTRA_CREATE_NEW_TAB / EXTRA_APPLICATION_ID extras only influence browsers, so they do nothing for a link a non-browser app has registered to handle.

  3. LongClickCopySpan — never overrode onClick, so it inherited URLSpan's bare startActivity. This is the click target where the span is installed directly with no UrlClickHandler: the story viewer's captions and the submit-debug-log dialog.

LongClickCopySpan now delegates to openBrowserLink instead of re-implementing the launch, so this reduces the number of parallel link launchers rather than adding another. encourageNewBrowserTab() is renamed prepareExternalLink(), since it no longer only concerns browser tabs.

Scope. Only observable when a non-browser app handles the link. Chrome's ChromeTabbedActivity is singleTask and always takes its own task regardless of the flag, so browser links behave identically before and after.

Why NEW_TASK is safe here

  • No Custom Tabs anywhere in the tree, so nothing relies on staying in-task.
  • Signal's own https handlers (StickerPackPreviewActivity, DeepLinkEntryActivity) both use the default taskAffinity, so a signal.me / signal.art link reuses the existing Signal task rather than creating a second Signal card.
  • Back navigation is preserved — verified on device, Back from the other app still returns to Signal.

Verification

Unit tests in LinkActionsTest and LongClickCopySpanTest; the flag assertions fail against current main and pass with this change (confirmed by stashing each fix independently).

On device (Vivo V2036, Android 13), same build and same tap, only the patch differing:

Signal task other app's task flags Signal sent
main 359 359 (same task, sz=2) 0x0
with fix 361 362 (own task) 0x10000000

On main the single Recents card is labelled "Signal" but its contents are the other app, and Back from it lands on Signal's own screen. With the fix there are two cards.

@theabhishekchandra

Copy link
Copy Markdown
Contributor Author

Before settling on NEW_TASK I measured the alternatives on device (Vivo V2036, Android 13). Each case opens a link twice, since a single open hides duplicate-task behaviour.

A link another app handles — the reported case

flags its Recents cards Back goes to
none — main today 0, inside Signal's the other app
NEW_TASK — this PR 1 ✅ Signal
NEW_TASK|MULTIPLE_TASK 2 ❌ the other app
NEW_DOCUMENT 1 Signal
NEW_DOCUMENT|MULTIPLE_TASK 2 ❌ the other app
NEW_TASK|CLEAR_TOP 1 Signal

A link Signal handles itself — signal.me, signal.art

flags Signal's Recents cards
none — main today 1
NEW_TASK — this PR 1 ✅ reuses the existing task
NEW_TASK|MULTIPLE_TASK 3 ❌
NEW_DOCUMENT|MULTIPLE_TASK 3 ❌

Both MULTIPLE_TASK variants are worse than the bug: they duplicate the other app's card and spawn extra Signal cards for Signal's own deep links. NEW_TASK is the only option that separates the tasks with no side effect.

This also confirms by measurement what the description argues from the manifest — a signal.me link stays in the existing Signal task rather than creating a second one.

Fixes signalapp#14946

Tapping a link that a non-browser app handles (e.g. x.com with the X app
installed) launches that app's activity into Signal's own task. Recents
then shows a single card carrying Signal's icon and label whose contents
are the other app, and the other app sits on top of Signal's back stack
rather than beside it.

Three separate paths hand a URL to another app, and none set
FLAG_ACTIVITY_NEW_TASK:

- LinkActions.openUrl, reached from CommunicationActions.openBrowserLink
  (~71 call sites). It set the flag only when the context was not an
  Activity. That condition comes from 26cb17e "Fix browser not opening
  in certain contexts", which added it because Android *requires* the flag
  from a non-Activity context. It was never a decision about task
  placement; on the Activity path the flag was simply absent.

- ConversationFragment.openLink, used by onUrlClicked and
  onLinkPreviewClicked, which builds its own intent and never goes through
  LinkActions. The Browser.EXTRA_CREATE_NEW_TAB / EXTRA_APPLICATION_ID
  extras it sets only influence browsers, so they do nothing for a link
  claimed by a non-browser app.

- LongClickCopySpan, which did not override onClick and so inherited
  URLSpan's bare startActivity. This is the click target wherever the
  UrlClickHandler declines: the story viewer (which uses the span
  directly), the long-message view, message details and starred messages.

Set FLAG_ACTIVITY_NEW_TASK on all three so the handling app gets its own
task, as it would when the same link is opened anywhere else on the
device. LongClickCopySpan now routes through openBrowserLink rather than
re-implementing the launch, so the number of parallel link launchers goes
down rather than up. Renamed encourageNewBrowserTab() to
prepareExternalLink(), since it no longer only concerns browser tabs.

Note this is only observable when a non-browser app handles the link.
Chrome's ChromeTabbedActivity is singleTask and always takes its own task
regardless of the flag, so browser links are unaffected either way.

Signal's own https handlers, StickerPackPreviewActivity and
DeepLinkEntryActivity, both use the default taskAffinity, so a signal.me
or signal.art link still resolves into the existing Signal task rather
than creating a second one.

Added LinkActionsTest and LongClickCopySpanTest; the flag assertions fail
against current main.
@theabhishekchandra
theabhishekchandra force-pushed the fix/external-app-task-14946 branch from 6175509 to dbf3b02 Compare September 3, 2026 21:21
@alex-signal

Copy link
Copy Markdown
Contributor

Have you looked at whether group links work appropriately? Ideally those could be just launched in the same process (since ultimately they open a group chat that you can choose to join)

@theabhishekchandra

Copy link
Copy Markdown
Contributor Author

Yes, group links still work. This PR does not change how they are handled. I checked the code and then tested on a real device (Vivo V2036, Android 13, running this PR).

Group links in a chat never leave Signal. When you tap a group link in a conversation or in the long-message view, handlePotentialGroupLinkUrl runs first, before any Intent is created. It opens the join sheet directly. This PR does not touch that code.

Other places already send the link out and back. Link previews, story captions, group descriptions, and the openBrowserLink callers send an ACTION_VIEW Intent. Android gives it to DeepLinkEntryActivity. That activity uses the default taskAffinity, so even with FLAG_ACTIVITY_NEW_TASK it opens inside the existing Signal task. It then calls MainActivity.clearTop(), which already sets NEW_TASK on its own, and the join sheet appears. Nothing new here, this is how main works today too.

What I measured. Same signal.group link, tested two ways, with adb logcat -b events recording:

how the link was opened new activities Signal task before → after result
tapped in a chat none 486 → 486 join sheet opens over the chat
ACTION_VIEW + NEW_TASK (what the other surfaces send) DeepLinkEntryActivity, inside task 472 472 → 472 join sheet opens over the chat list

Row 1: no activity was created at all. The join sheet was shown by the fragment itself.

Row 2, run three times with the same result:

wm_task_to_front:    [0,472]
wm_create_activity:  [0,239799657,472,org.thoughtcrime.securesms/.deeplinks.DeepLinkEntryActivity,android.intent.action.VIEW,NULL,sgnl://signal.group/...,0x17400000]
wm_finish_activity:  [0,239799657,472,...DeepLinkEntryActivity,clear-task-stack]
wm_new_intent:       [0,54628129,472,org.thoughtcrime.securesms/.RoutingActivity,android.intent.action.MAIN,...]

DeepLinkEntryActivity opens inside the same task 472, with the NEW_TASK flag set. It hands over to the main screen in that same task. Recents still shows one Signal card.

The test link points to a group that does not exist, so both sheets say "Link error". That is the normal join sheet doing its server check, same as it would for a real link.

(Small note about my device: it has both the release and staging builds installed, and both are verified for signal.group. Because of that, Android gives the https link to Chrome first, Chrome redirects to sgnl://, and Android asks which build to use. With only one Signal installed, the link goes straight to DeepLinkEntryActivity. The task result is the same either way.)

If you want link previews and story captions to also handle group links inside Signal, like the chat does, I can add that here or in a follow-up. onLinkPreviewClicked can call handlePotentialGroupLinkUrl directly. LongClickCopySpan only has a Context, so it would need to find the FragmentActivity first.

@alex-signal

Copy link
Copy Markdown
Contributor

Have you tested back-gesture support after entering different applications?

@theabhishekchandra

Copy link
Copy Markdown
Contributor Author

Yes, I tested it. Phone: Vivo V2036, Android 13, gesture navigation turned on, running this PR.

where I tapped the link app that opened where the back swipe took me
Settings → Help → Support center Chrome back to the Help screen
Settings → Help → Support center a test app that opens in its own task back to the Help screen
inside a chat the same test app back to the chat
inside a chat Play Store back to the chat

In every case Signal sent the NEW_TASK flag and the other app got its own task. One back swipe from the other app brings you back to the exact Signal screen you left. After that, back works inside Signal as usual.

Two things to know. Both are normal Android behaviour, not something this PR adds:

  • If you press Home first and then open the other app again, back goes to Home, not to Signal. Any app opened on its own works this way.
  • The other app keeps its card in Recents after you swipe back. Android 12 and newer does not close an app's first screen on back, it just moves it behind.

I do not have an Android 14 or newer device, so I could not watch the predictive back animation. I could only check where the gesture ends up.

The span is only installed directly, with no UrlClickHandler, by the
story viewer's captions and the submit-debug-log dialog. Message details
and starred messages never reach LongClickCopySpan.onClick.

Claude-Session: https://claude.ai/code/session_01DR838Sy4oDGKGyVEFxL8Xa
@alex-signal

Copy link
Copy Markdown
Contributor

Hi! Thanks for your submission. A condensed version is in 8.27 and I attributed you as the author. In the future, it'd be great to include comments that are written in your own words summarizing your work, as Claude can be very chatty.

@theabhishekchandra

Copy link
Copy Markdown
Contributor Author

Thank you, Alex. I am happy to contribute and work with you. I understood your feedback on my comments. Mostly, I write in my own words and just ask an AI agent to explain them in simple words so the maintainer can understand and verify easily. it was a bit too chatty because I thought I needed to explain everything. I understand now. Next time, I will write it in my own words.

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