Skip to content

Share: stage attachments instead of sending on selection, and resolve only content:// URIs - #2222

Merged
mpretty-cyro merged 2 commits into
devfrom
fix/share-intent-uri-handling
Sep 28, 2026
Merged

mpretty-cyro merged 2 commits into
devfrom
fix/share-intent-uri-handling

Conversation

@mpretty-cyro

@mpretty-cyro mpretty-cyro commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Share: stage attachments instead of sending on selection, and resolve only content:// URIs

Changes in the share-intent and attachment path. The first is the only user-visible one.

Attachments are staged rather than sent on selection

Sharing a document into Session, or picking one with + → File, sent it the moment a conversation
was chosen — no preview, no chance to add a message, no confirmation. It now waits in the input bar
with its name, size and a control to drop it again, and sends when you say so, with whatever message
you typed alongside it. ConversationActivityV2 had a TODO asking for exactly this.

GIFs picked from Giphy behave the same way, since they shared the listener. Picking from the library
and taking a photo are unchanged — those already went through the media editor.

This needed three pieces that were not there before: the input bar had nothing to draw a staged
attachment with, the send button only appeared once there was text, and sendMessage did not consult
the attachment manager. So AttachmentDraftView joins the quote and link-preview drafts in the input
bar's additional-content container, the send button arms for a staged attachment as well as for text,
and send includes it.

This is the part worth arguing about, and it is a product decision rather than a tidy-up.

The share path resolves content:// URIs only

ShareActivity is exported, so the URIs in the Intent it receives come from whichever app invoked the
share sheet. ContentResolver.openInputStream accepts three schemes — content://, file:// and
android.resource:// — and only content:// involves a URI grant; the other two it opens directly as
us. The share path wants the granted case, so it resolves that one and declines the rest. The copy the
app-lock path makes of incoming shared files does the same.

Nothing we ship sends us anything else. MediaPreviewActivity.forward() is ShareActivity's only
in-app caller and passes a content:// attachment URI, and a sending app targeting API 24 or above
gets FileUriExposedException in its own process for a file:// extra.

URIs naming our own providers are passed along only for an Intent we built

ShareViewModel hands a URI matching PartAuthority.isLocalUri straight to the attachment manager
rather than resolving it. That is right for MediaPreviewActivity.forward(), which forwards an
attachment we already hold, and is not right for an Intent that arrived from outside — our own
providers answer us whether or not they are exported, so such a URI resolves against our own data.

That branch is now taken only for the exact URIs a token was minted for, rather than for any Intent
carrying a token: the system chooser merges a direct-share target's extras into the sender's Intent,
so a token we minted can arrive attached to URIs another app chose. The resolve path declines these
URIs too, and our own FileProvider is refused on the same footing — its configured roots include the
cache directory and external storage.

The share destination travels as a token

DirectShareService put the target Address into the ChooserTarget bundle and ShareViewModel read
it back out of the incoming Intent, so the destination was carried in the Intent itself. It now mints
an opaque token per target and holds token -> Address in memory; ShareViewModel resolves the token
and no longer reads an Address from the Intent. MediaPreviewActivity.forward() mints a token for
the URI it is forwarding and no destination, so it still reaches the contact picker.

Direct share behaves exactly as before. Worth knowing for scope: ChooserTargetService was deprecated
at API 30 and Android stopped consulting it at API 31, so with minSdk = 26 this code path is live on
API 26–30 only. Migrating to ShortcutManager sharing shortcuts would restore direct share on 12+, but
that is its own piece of work.

Cached share filenames are reduced to a single path segment

The name the app-lock path caches a shared file under is the sending app's OpenableColumns.DISPLAY_NAME
verbatim. That is a display name, not a path segment, so it is now reduced to its last segment before
being joined to cacheDir, and a name that reduces to nothing usable (., .., empty) is declined.
Fixed at the call site rather than in FilenameUtils.getFilenameFromUri, whose many other callers want
a display name and not a path segment.

A Giphy result is given a filename

GiphyActivity wrote the blob without one, so the attachment was named null verbatim — and carried
that to the recipient. Invisible until now, because a GIF renders as an image rather than by name; the
input bar draft is what surfaced it.

Two decisions a reviewer may want to push back on

  • The token is not retired when it is resolved. With app lock on, one share creates ShareActivity
    twice — once before routeApplicationState sends it to the lock screen, once from the Intent the lock
    screen replays — and each instance resolves the Intent independently, so a single-use token would be
    spent before the user ever authenticates. Retention is capped at 512 entries, oldest first, instead.
  • GIFs now stage rather than send. Consistency with the document picker; it was the same listener.

Testing

20 unit tests across ShareViewModelTest, ShareIntentTokenStoreTest and
ShareIntentCacheFilenameTest. Full suite: 320 tests, 0 failures, 0 skipped.

The staging behaviour cannot be unit tested here — ConversationActivityV2 is not constructible in the
JVM suite — so it was exercised on an emulator (API 37, play debug), each step confirmed on screen:

flow result
+ → File, pick a PDF staged in the input bar; send button replaced the microphone; sending delivered it
Share a PDF in from the Files app contact picker shown, then staged; typed a message alongside it; both delivered
+ → GIF, pick from Giphy staged with its thumbnail and a real filename; sending delivered it
The draft's remove control attachment dropped, microphone returned, nothing sent

…e attachments

- ShareActivity resolves only content:// URIs, and not ones naming our own
  providers; other schemes are refused rather than opened directly.
- The share destination travels as a token minted by DirectShareService and
  MediaPreviewActivity rather than as a parcelled Address in the Intent, and an
  Address in the Intent is no longer read. Direct share is unchanged on the API
  levels where it still runs: ChooserTargetService stopped being consulted at
  API 31, so that is 26-30.
- The filename a shared file is cached under is reduced to a single path segment
  before being joined to cacheDir, and the app-lock cache path resolves
  content:// URIs only.
- An attachment that is shared in, or picked as a document or a GIF, now waits in
  the input bar with send armed instead of being sent the moment it is chosen,
  which the document picker had a TODO asking for. Picking from the library or
  the camera is unaffected - it already went through the editor.
…heir URIs

Follows review on the previous commit.

- A staged attachment had no way to be seen or sent: the input bar drew nothing
  for it, the send button only appeared once there was text, and sendMessage
  never consulted the attachment manager. A shared or picked file was therefore
  discarded. It now shows in the input bar with its name, size and a control to
  drop it again, the send button arms for an attachment as well as for text, and
  send includes it along with any message typed alongside.
- A share token now names the URIs it speaks for rather than merely existing. The
  chooser merges a direct-share target's extras into the sender's own Intent, so
  a token minted here can arrive attached to URIs chosen by another app.
- Our own FileProvider is refused on the same footing as the attachment and blob
  providers, on both the share and app-lock paths; its configured roots include
  the cache directory and external storage.
- Name the blob a Giphy result is written to. Without one it is named "null"
  verbatim, which the attachment then carries to the recipient.

@Bilb Bilb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mpretty-cyro
mpretty-cyro merged commit 07cfffb into dev Sep 28, 2026
5 checks passed
@mpretty-cyro
mpretty-cyro deleted the fix/share-intent-uri-handling branch September 28, 2026 06:02
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