Skip to content

Share: confirm before opening non-http(s) URIs from scanned content#646

Open
dsremo wants to merge 2 commits into
markusfisch:masterfrom
dsremo:dsremo/scheme-guard
Open

Share: confirm before opening non-http(s) URIs from scanned content#646
dsremo wants to merge 2 commits into
markusfisch:masterfrom
dsremo:dsremo/scheme-guard

Conversation

@dsremo
Copy link
Copy Markdown

@dsremo dsremo commented May 15, 2026

Problem

Right now any URI a QR code resolves to is handed straight to Intent.ACTION_VIEW the moment the user taps the open icon in the action sheet. For http/https/content/file that's the expected behaviour, but for tel: / sms: / mailto: / intent: / arbitrary custom schemes it means a malicious QR can:

  • Auto-dial a premium-rate number via tel:
  • Pre-compose an SMS to a short-code via sms: / smsto:
  • Hand off to an arbitrary app via intent://com.target.app/...#Intent;...;end
  • Trigger a custom-scheme deep link the user didn't realise was a privileged action

…before the user has any chance to read the actual destination. The action sheet shows the parsed content, but the act of tapping the open icon commits the launch.

Fix

A single AlertDialog before openUri() launches the intent — but only for schemes other than http, https, content, file. http(s) URLs continue to open immediately as before — zero added friction for the common case.

The dialog shows:

  • The full URI
  • A human-readable description of which app it would open: "phone dialer", "SMS composer", "MMS composer", "email composer", or "external app (\<scheme\>)" for anything else

Default action is Cancel; positive button is "Open".

What changes

  • app/src/main/kotlin/de/markusfisch/android/binaryeye/content/Share.kt (+41): openUri() now branches by scheme; non-standard schemes route through a new AlertDialog. Standard schemes (and silent=true calls) go to a small extracted launchUri() helper that preserves the original behaviour (FLAG_GRANT_READ_URI_PERMISSION for content://, execShareIntent vs startIntent based on silent).
  • app/src/main/res/values/strings.xml (+8): five scheme-label strings, dialog title, body template, "Open" button label.

No new permissions, no new dependencies, no UI changes outside the new dialog.

Tested

  • Scanned a QR encoding tel:+12025551234 → dialog shows "phone dialer" + URI → Cancel does nothing, Open launches the dialer ✓
  • Scanned an https://example.com/... URL → opens immediately, no dialog (same as today) ✓
  • Scanned a mailto:test@example.com?subject=... → dialog shows "email composer" ✓
  • Scanned an intent://...#Intent;...;end → dialog shows "external app (intent)" ✓
  • Sharing flow (silent=true path) → bypasses dialog as expected ✓

Notes

  • The silent parameter on openUri() is preserved untouched — bypasses the confirmation since silent callers are internal openers (the user has already approved through another flow).
  • The dialog only fires when the calling Context is an Activity (which it always is in practice from the action sheet). If somehow called from a non-Activity Context, the call falls back to the original behaviour.
  • If you'd prefer this to be opt-in behind a setting (e.g., for users who scan a lot of tel: codes legitimately), happy to add a toggle.

Currently any URI a QR code resolves to is handed straight to ACTION_VIEW
the moment the user taps Open. For http(s)/content/file that's the
expected behaviour, but for tel:/sms:/mailto:/mms:/intent:/etc. it means
a malicious QR can auto-dial a premium-rate number, pre-compose an SMS
to a short-code, or hand off to an arbitrary app via a deep-link
intent:// before the user can react.

This adds a single AlertDialog before openUri() launches an intent for
any scheme other than http/https/content/file. The dialog shows the
full URI and a human-readable description of which app it would open
('phone dialer', 'SMS composer', etc.), with Cancel as the default
action. http(s) URLs continue to open immediately as before — the new
path adds zero friction for the common case.

Silent callers (silent=true) bypass the confirmation since they're
internal openers (eg. user has already approved via the action sheet
in another flow).

8 new strings + 41 lines in Share.kt. No new permissions, no new
dependencies, no UI changes outside the new dialog.
@markusfisch
Copy link
Copy Markdown
Owner

Thanks for the contribution! An additional dialog really makes sense, but…

Scanned a mailto:test@example.com?subject=... → dialog shows "email composer“ ✓

if I scan a mailto barcode, for example this one, I don’t see any additional dialog with this branch, but just the same Mail FAB with the default branch. Pressing it will open the E-Mail app. Again without any dialog. Did I miss something here? 🤔

test

Also please always add all translations since ./gradlew lintDebug will throw errors for missing translations. With AI, this is trivial, of course 😉

@dsremo dsremo force-pushed the dsremo/scheme-guard branch from 4099dab to ba61e40 Compare May 17, 2026 15:46
The Mail/SMS/Phone FABs on the action sheet go through
IntentAction.execute() -> execShareIntent(), which bypassed the
consent dialog added in the previous commit (that path only guarded
the generic Open icon's openUri() flow).

Extract confirmSensitiveAction() and schemeNeedsConsent() helpers,
then add execShareIntentWithConsent(intent) which inspects the
intent's data URI and gates on the same scheme list. IntentAction
now calls the new function so mailto/tel/sms/MATMSG barcodes prompt
the user whether they tap the Mail/SMS/Phone FAB or the generic Open
icon. Intents with no data URI (calendar add, vcard import) and
intents to safe schemes (http/https/content/file) pass through.

Translate the 8 new scheme_* strings into all 25 existing locales so
./gradlew :app:lintDebug stays clean.
@dsremo
Copy link
Copy Markdown
Author

dsremo commented May 17, 2026

Fixed both points.

The Mail FAB goes through IntentAction.execute() → execShareIntent(), not openUri(), so my dialog never fired on that path. Refactored Share.kt to extract the dialog logic into confirmSensitiveAction() + schemeNeedsConsent(), and added a new Context.execShareIntentWithConsent(intent) that inspects intent.data?.scheme and reuses the same dialog. IntentAction.execute() now calls that helper, so all four sensitive-scheme subclasses (MailAction, MatMsgAction, SmsAction, TelAction) are guarded. VCardAction and VEventAction have no data URI and pass through unchanged.

Found one more path but since it is meant for the automated opening and is already user-authored so probably the person would not want the dialog there so letting it be independent.
--> AutomatedActionRunner.kt:execCustomIntent()

Translations added for all 25 existing locales; ./gradlew :app:lintDebug is clean. : Yes AI is really helpfull in monotonous tasks 😁

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