Skip to content

feat(scanner): scan an image shared into the app - #1552

Draft
bmc08gt wants to merge 4 commits into
code/cashfrom
feat/scan-from-shared-image
Draft

bmc08gt wants to merge 4 commits into
code/cashfrom
feat/scan-from-shared-image

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Phase 2 of scan-from-gallery. Flipcash now appears in the share sheet for an image, and a shared image is scanned by the same path Phase 1 built for a picked one — the Kik-code ladder, then a QR fallback, then DeeplinkType.isScannable.

Phase 1 is #1509; the ScanFromGallery flag it shipped behind was retired in #1520, so this is unconditional.

How it fits together

A share can't reuse the deeplink route: Rinku's DeepLinkListener only sees intent.data, and the image arrives in EXTRA_STREAM. So MainActivity takes the Uri off the intent and hands it to a @Singleton SharedImageChannel, mirroring the existing EmailCodeChannel / LocalEmailCodeChannel pair.

The tab switch happens in App.kt rather than in the scanner, because Scanner() isn't composed while another tab is showing. Scanner() then consumes the pending Uri and calls the existing onImagePicked lambda unchanged.

The Uri is cleared before the scan starts, not after, so a failed scan isn't retried on every recomposition and a rotation mid-scan doesn't start a second search.

Why isScannable carries more weight here

A picked image is one the user chose. A shared image is one somebody else put in front of them, which is the shape of a phishing attempt against the login route — a crafted /login QR is an account takeover if it's allowed through. isScannable is the whole defence, and DeeplinkScanPolicyTest already asserts it, so this extends that test rather than adding a parallel one.

Making the app a share target turned out to open a second way in. The share filters are the only ones on MainActivity that don't pin a scheme and host, and a filter naming just a mime type accepts content: and file:. Rinku is installed with no DeepLinkFilter, so the listener routes whatever is in intent.data, and isLogin() matches on the first path segment alone. A sender could therefore attach content://evil.example/login/#/e=... to an ordinary image share and reach tapped-link handling, which — unlike the scan path — will open /login.

consumeSharedImage clears data and removes EXTRA_STREAM on any SEND/SEND_MULTIPLE, before super.onNewIntent, which is where the listener runs. It can't wait until an image is found, because a sender after the deeplink just omits the image. A VIEW intent is left untouched, so App Links still work.

Also worth a look

  • An image shared during onboarding or on the restriction screen used to navigate away from it, and navigateAll clears the back stack. The gate now requires AuthState.Ready, and holds rather than decides while auth is still resolving.
  • The scan ran under the biometric app lock — the overlay draws over the scanner rather than replacing it, so the effect beneath still ran, and a shared cash link would sign and submit a grab on a locked app.
  • Removing EXTRA_STREAM also stops singleTask replaying the same share on every restore from recents after a process death.

Known, and deliberately not fixed here

The reused path emits the whole gallery funnel, not just its first event: galleryImagePicked, then galleryScanSucceeded or galleryScanFailed, all from inside the onImagePicked lambda that both entry points now share. So a shared image counts as a gallery pick at every stage, and Phase 1's funnel over-reports throughout rather than only at the top.

Fixing it means a Source property on the three Gallery Scan entries in libs/analytics-events/events.toml (lines 402-426) — a change to the shared cross-platform contract, plus a parameter on three call sites here. iOS doesn't send these through the contract yet; its versions drift by name, as those entries' own drift notes record. So the property wants settling across both platforms rather than landing here. Raised with the sessions working on the contract, and nothing in this PR forecloses it.

The iOS half of Phase 2 is a share extension — a different mechanism, shipping independently.

An `image/*` SEND filter on MainActivity, so Flipcash appears in the share sheet
for an image. Nothing handles the intent yet — that is the next commit.

SEND_MULTIPLE is declared alongside SEND because some senders use it for a single
image; the handler takes the first item and drops the rest.

MainActivity is already `singleTask`, so a share into a running app arrives at
`onNewIntent` rather than creating a second instance over the camera session. No
launch-mode change was needed.
MainActivity's SEND filter (previous commit) puts Flipcash in the share
sheet, but nothing yet reads the shared image back out. sharedImageUri
is that read: it takes an image/* SEND or SEND_MULTIPLE and returns the
Uri to scan, and null for anything else.

Placed in apps/flipcash/core rather than MainActivity because the app
module has no androidTest source set or Robolectric dependency to test
a real Intent/Uri with; core already carries both. The function is
public so MainActivity can call it once the channel wiring lands.

It has to return null for ACTION_VIEW: MainActivity already routes
tapped app-link Uris through the deeplink handler, and this must not
compete with that path for the same intent.
An image shared to Flipcash now runs the same path a picked one does: the
Kik ladder, then QR, then the `isScannable` allowlist, then the existing
result handling. Reusing `onImagePicked` is deliberate — the allowlist is
what keeps a crafted `/login` QR in a received image from reaching the
login route, and a second entry point that scanned images its own way
would be a second place for that guard to go missing.

The payload can't travel the deeplink path: Rinku's `DeepLinkListener`
only sees `intent.data`, and a share carries its image in `EXTRA_STREAM`.
So `SharedImageChannel` holds the `Uri` from the moment the intent lands
until the Scan tab composes, the way `EmailCodeChannel` already carries a
verification code. It's a `StateFlow` rather than a one-shot emission
because the tab subscribes late, after the navigation.

The navigation lives in `App.kt` next to the deeplink effect, since
`Scanner()` isn't composed while another tab is showing. `Scanner()` then
clears the channel before calling the handler, so a failed scan isn't
retried on every recomposition and a rotation mid-scan doesn't start a
second search.

A share that arrives while signed out is dropped rather than held across
the sign-in — there's no Scan tab to route to, and an image somebody sent
isn't worth resurrecting after an account switch.
The share filters are the only ones on `MainActivity` that don't pin a scheme
and host. A filter naming just a mime type accepts `content:` and `file:`, so a
sender can hang an arbitrary data `Uri` off an otherwise ordinary image share.
`Rinku` is installed with no `DeepLinkFilter`, so the listener reads
`intent.data` and routes whatever is there, and `isLogin()` matches on the first
path segment alone — no host or scheme check. That reaches tapped-link handling,
which is allowed to open `/login`; the scan path refuses it via
`DeeplinkType.isScannable`. So the filter added for scanning also handed a
sender the one route that bypasses the guard the feature exists to enforce.

`consumeSharedImage` now clears `data` and removes `EXTRA_STREAM` on any
`SEND`/`SEND_MULTIPLE`, and `MainActivity` calls it before `super.onNewIntent`,
which is where the listener runs. The strip can't wait until an image is found:
a sender that wants the deeplink rather than the scan simply omits the image.
Removing `EXTRA_STREAM` also stops `singleTask` replaying the same share on
every restore from recents after a process death.

Three narrower fixes alongside it:

- The auth gate only dropped a share on `LoggedOut`, so one arriving during
  onboarding or on the restriction screen navigated away from it. `navigateAll`
  clears the back stack, leaving nothing to return to. It now requires `Ready`
  and holds — rather than decides — while auth is still resolving.
- The scan ran under the biometric app lock. The overlay draws over the scanner
  rather than replacing it, so the effect beneath still ran, and a shared cash
  link would sign and submit a grab on a locked app. Held until `passed`.
- The effect re-runs on every route change, so it navigated a second time once
  already on the Scan tab.

Tests cover the strip, its unconditional application, the replay, and that a
`VIEW` intent is left untouched so App Links keep working.
@bmc08gt bmc08gt self-assigned this Sep 24, 2026
@github-actions github-actions Bot added type: feature New functionality area: scanner QR/Kikcode scanning, camera labels Sep 24, 2026
@bmc08gt
bmc08gt marked this pull request as draft October 2, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: scanner QR/Kikcode scanning, camera type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant