Repository navigation
Conversation
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
marked this pull request as draft
October 2, 2026 12:44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ScanFromGalleryflag 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
DeepLinkListeneronly seesintent.data, and the image arrives inEXTRA_STREAM. SoMainActivitytakes theUrioff the intent and hands it to a@Singleton SharedImageChannel, mirroring the existingEmailCodeChannel/LocalEmailCodeChannelpair.The tab switch happens in
App.ktrather than in the scanner, becauseScanner()isn't composed while another tab is showing.Scanner()then consumes the pendingUriand calls the existingonImagePickedlambda unchanged.The
Uriis 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
isScannablecarries more weight hereA 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
/loginQR is an account takeover if it's allowed through.isScannableis the whole defence, andDeeplinkScanPolicyTestalready 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
MainActivitythat don't pin a scheme and host, and a filter naming just a mime type acceptscontent:andfile:. Rinku is installed with noDeepLinkFilter, so the listener routes whatever is inintent.data, andisLogin()matches on the first path segment alone. A sender could therefore attachcontent://evil.example/login/#/e=...to an ordinary image share and reach tapped-link handling, which — unlike the scan path — will open/login.consumeSharedImageclearsdataand removesEXTRA_STREAMon anySEND/SEND_MULTIPLE, beforesuper.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. AVIEWintent is left untouched, so App Links still work.Also worth a look
navigateAllclears the back stack. The gate now requiresAuthState.Ready, and holds rather than decides while auth is still resolving.EXTRA_STREAMalso stopssingleTaskreplaying 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, thengalleryScanSucceededorgalleryScanFailed, all from inside theonImagePickedlambda 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
Sourceproperty on the three Gallery Scan entries inlibs/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' owndriftnotes 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.