Skip to content

feat(chat): web link previews - #1732

Open
bmc08gt wants to merge 37 commits into
code/cashfrom
feat/web-link-previews
Open

bmc08gt wants to merge 37 commits into
code/cashfrom
feat/web-link-previews

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Rich-link spec Phase 2 for Android. An outside https link in a chat message now gets a preview card, built from the page's <head>, drawn inside the bubble under the text. Members of a group and anyone in a DM see it automatically. Non-members of a group get a "Show preview · " chip first, so viewing a group they haven't joined never makes a request to a site they didn't choose. iOS is doing the same work in parallel, and both apps are held to the shared link_detection.json and link_metadata.json fixtures from the orchestrator.

What changed

  • Detection and classification. LinkCard.Web joins the Flipcash card types. A message gets one card: the first Flipcash card in text order, otherwise the first eligible web link. The chat detector now drops a closing *, _ or ~ when the same marker opens the link, so it passes every detection vector.
  • Fetching. WebLinkLookup makes the page request on a pinned OkHttp client: no cookies, no cache, no proxy, no automatic redirects, and PublicOnlyDns refusing private and special-use addresses on every hop. The body is capped at 512 KiB uncompressed, there are at most 4 requests per page, and one lookup gets 10 s in total. WebPageParser reads the head, and the fixture's 20 pages vectors pass.
  • Memory. Resolved cards are kept for 168 h and empty answers for 24 h, both in memory and in the existing link-card table (web:<cacheKey> rows, so no schema change). Failures are never stored.
  • Prefetch and rendering. Only the open chat is prefetched, and only when automatic previews apply. The card is a panel with a 1.91:1 image, host, title and description, and opens through the existing "leaving Flipcash" warning. Images go through a separate client that applies the page rules.
  • Link-only messages. When a message is just the link and its card has resolved, the card is drawn on its own with the bubble's corners, as Flipcash cards already are. While it loads, a card-shaped placeholder (host, image slot, shimmer) holds the place, so the URL never shows; if no card comes back, the message becomes a normal text bubble with the link. Every card takes double-tap to react. The cash card's Claim pill is now its own target and claims on the first tap.
  • Opening a chat. Preview images are also kept on disk, 20 MB in a store of their own, deleted with their row, so a relaunch doesn't refetch them. Saved rows load at app start, and a chat's first draw waits up to 300 ms for them, so a card that was resolved before doesn't flash its placeholder.
  • Flag. FeatureFlag.WebLinkPreviews is on by default and not launched, so staff can turn it off. When it's off, there's no web card, chip, prefetch or image fetch.
  • Removed libs:opengraph, which nothing used.

The fetcher had a red-team pass before it was wired in. It found a system proxy bypassing the address check, unfiltered NAT64, 6to4 and special-use ranges, escaped hosts in redirects, and a per-request timeout that let one lookup hold a slot for about 40 s. Each fix has a test that fails without it.

Parity decisions

Agreed with the iOS session; the iOS PR lists the same items.

  • D1 Request headers: page = UA "Mozilla/5.0 (compatible; FlipcashLinkPreview/1.0)", Accept "text/html,application/xhtml+xml", Accept-Encoding "identity"; image = same UA, Accept "image/*", Accept-Encoding "identity". No Cookie/Authorization. Uncompressed body capped at maxBodyBytes (page) / maxImageBytes (image). (Host/Connection are transport-added, not counted.)
  • D2 Response with Content-Encoding other than absent/"identity" -> None (cached as empty), checked before parsing.
  • D3 At most 4 requests per page (first + 3 redirects); a 3xx on the 4th -> None (cached). Images fail on the same count.
  • D4 Eligibility, cache key, DNS and TLS use the ASCII (punycode) host; an unconvertible host gives no card; the displayed host is ASCII (punycode), not decoded to Unicode.
  • D5 Gate: auto-fetch for any open DM / non-group chat; members-only applies to groups.
  • D6 Image fails on a Resolved card -> card still draws host/title/description with no image slot (no placeholder).
  • D7 Image response with non-identity Content-Encoding fails the image (then D6).
  • D8 Persisted row: key "web:", JSON {"title","description","imageUrl","host"} all nullable; null title = None; freshness from row updatedAt: Resolved 168h, None 24h; failures never written.
  • D9 A chat's first page loaded before it counts as active resolves web cards at draw time, not prefetch; accepted.
  • D10 Both detectors pass every link_detection.json vector. A trailing *, _ or ~ is dropped (once) only when the same marker directly precedes the link (text-format spec decision 1).
  • D11 A host whose raw text contains '%' (e.g. https://ex%61mple.com/) gives no card on both apps; checked on the raw authority before URL parsing.
  • D12 An explicit port other than 443 gives no card and is never fetched; checked on the first URL and on every redirect Location (a hop on another port -> None).
  • D13 A host ending in '.' or made only of numeric labels gives no card. A numeric label is all decimal digits, or "0x" followed by one or more hex digits ("127.1", "0x7f.0.0.1", "2130706433" refused; "cafe.be", "0xide.com", "1password.com" kept).
  • D14 Address rule additions: 64:ff9b::/96 (NAT64) and ::ffff:0:0/96 judged by their embedded IPv4; 2002::/16 and ::/96 always private; 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 240.0.0.0/4 private. Covered by local unit tests; no fixture rows yet.
  • D15 Both apps time out a read that waits more than 5s for the next bytes (iOS adds a per-read timeout; Android already has readTimeout 5s).
  • D16 The D11 escaped-host check runs on a redirect's resolved host: an absolute or scheme-relative ("//host") Location is checked, a relative path keeps the current host, a backslash form is rejected (None).
  • D17 Repeated list headers join with ", "; every Content-Encoding value must be identity (an empty or blank token is not identity), else None. A repeated Location or Content-Length with different values is a failure (not cached); an identical repeat is fine.
  • D18 One 10s deadline per lookup across all hops; hitting it is a failure (not cached). The 5s idle read (D15) applies inside it.
  • D19 TTL is checked on every read, in memory and on load (exactly at the TTL still reads; 1 ms past reads as absent). Row JSON writes all four keys, nulls included.
  • D20 A chat whose type or membership is not yet known does not prefetch web links; the draw-time rule decides.
  • D21 One card per message: the first Flipcash card in text order; if none, the first web link in text order. Only that card is prefetched and drawn.
  • D22 With the WebLinkPreviews flag off there is no web card at all: no chip, no card, no prefetch, no image fetch.
  • D23 An ineligible web link is skipped; the next eligible web link in text order is used.
  • D24 Neither app sends a lookup through a system proxy (Android Proxy.NO_PROXY, iOS preferNoProxies), since a proxy would bypass the address check.
  • R1 The "Show preview" chip shows the host without a leading "www."; a resolved card shows the stored host.
  • R2 Tapping a resolved card opens card.url through the same "leaving Flipcash" warning as the link text; long-press goes to the message's long-press.
  • R3 The bubble takes full width only while a Resolved card draws; the chip and None keep the text-sized width.
  • R4 A failed fetch after tapping the chip shows nothing and asks again the next time the message is drawn (no chip).
  • R5 Chip copy "Show preview · " on both, pending UX review (user).
  • R6 A Loading web card in automatic mode draws nothing (no shimmer).
  • R7 A message whose text is only the web link (same trim rule as the Flipcash card split) draws no bubble once its card is Resolved: the card alone, with the bubble's corners for its run position and the URL text hidden. Loading, None, failed and the chip keep the text bubble with the link. Text plus link is unchanged.
  • R7a Every link card (Flipcash or web, bare or in a bubble) takes double-tap to react; a single tap on the card opens it after the double-tap window. An explicit button inside a card (cash voucher Claim pill, group invite View, "Show preview" chip) acts on the first tap and takes no double-tap.
  • R7b A Resolved card already in memory draws bare from the first frame; only a fresh lookup swaps bubble to bare.
  • R7c An expired Resolved entry that re-asks and comes back None or fails goes back to the text bubble, never an empty row.
  • R7d A bare web card keeps the message's place in its run and takes the bubble's corners for that place, as a Flipcash bare card does. The Edited marker goes where it does for a Flipcash bare card.
  • R7e A photo or media reply, or one part of a split message, never goes bare and draws no web card.
  • R8 A link-only message whose web card is loading in automatic mode draws a bare placeholder card in place of the text bubble, with the bare card's width, corners and panel. A resolved card fills it in place; None or a failure gives the text bubble with the link.
  • R8a The placeholder holds the 1.91:1 image slot and two title bars with a shimmer, plus the host from the link's own URL, "www." stripped as on the chip.
  • R8b Gestures on the placeholder are the bare card's: tap opens the link through the leaving-Flipcash warning, long-press acts on the message, double-tap reacts.
  • R8c Unchanged: text plus link while loading draws nothing under the text, and tap-to-load shows the chip in the text bubble. After the chip is tapped on a link-only message, the placeholder shows while it loads.
  • R8d The placeholder reads to a screen reader as the link's URL.
  • R8e A remembered answer draws its final form from the first frame: Resolved as the bare card, None as the text bubble. Only a fresh lookup shows the placeholder.
  • R8f With the flag off, a link-only message is a plain text bubble with no placeholder.
  • R8g A failed lookup gives the card None for that draw. The failure is not remembered, so the next appearance asks again.
  • L1 The card is a panel (white 8%) with the image flush to its top at full width and the text padded inside it.
  • L2 The "Show preview" chip is a filled pill (white 12%), not outlined.
  • L3 While the image loads, its 1.91:1 slot is held with a 15% tint; the slot is dropped only if the image fails.
  • L4 An image URL that fails the fetch rules (not https, port other than 443, ineligible or escaped host) is dropped when the page is parsed, so no slot is drawn.
  • L5 The image is drawn inside the 8% panel, which is clipped to the card's corners, so the fill sits behind a transparent image.
  • L6 A bare web card and its placeholder draw the bare group card's outline (1pt, white at 10%) over the image. A card inside a bubble has no outline.
  • P22a An image already in memory draws on the card's first frame.
  • P22b Images are also kept on disk, 20 MB total in a store of the preview's own, oldest dropped first. Only a successful, rule-passing image is ever written.
  • P22c An image is read from disk only while its row is fresh (Resolved within 168h), and is deleted when its row is dropped or expires. No separate image TTL; Cache-Control ignored. Key = the resolved imageUrl.
  • P22d Saved rows load at app start, not lazily; a chat's first draw waits for that load up to 300 ms, then draws with what it has.
  • P23a Page bodies are read up to 1 MiB (was 512 KiB).
  • P23b Reading stops at the first </head or <body (case-insensitive), including a marker split across two reads; with neither, the 1 MiB cap applies.
  • P24a A link answering 401, 403 or 404, or a 2xx HTML page with no title, falls back once to https://<final host>/, unless the final path is already / with no query. 410, 429, other 4xx and 2xx non-HTML give None; 5xx fails.
  • P24b The home fetch is a fresh request with the same headers and no cookies, sharing the link's redirect budget of 3 and its 10 s deadline. A home 5xx or timeout fails the whole lookup and nothing is stored.
  • P24c The card shows the home page's content; a tap opens the original link.
  • P24d The answer is stored under the original key, and the home answer under the home key (Resolved or None); a remembered home answer is used without fetching. If the shared redirect budget runs out during the fallback, the original key gets None and the home key gets nothing.
  • P25 An https link on flipcash.com or www.flipcash.com that does not classify as a Flipcash card becomes a web card, unless its first path segment is login, verify, c or cash, or it has a fragment. app.flipcash.com, send.flipcash.com and jump.flipcash.com never do (they carry cash-link and login fragments, or wrap other links).

Known differences (accepted)

  • iOS connects only to the first public address; Android (OkHttp) falls back to the next public address.
  • iOS deletes stale rows on load only past 168h (a None row 24–168h old stays on disk but reads as absent); Android deletes any row past its TTL on load. Not visible to users.

Sibling iOS PR: code-payments/code-ios-app#1025

@bmc08gt bmc08gt self-assigned this Oct 8, 2026
@github-actions github-actions Bot added type: feature New functionality area: ui Compose UI, theme, components, resources area: build-system Gradle, convention plugins, build-logic and removed type: feature New functionality labels Oct 8, 2026
bmc08gt added 24 commits October 8, 2026 19:06
link_detection.json and link_metadata.json come from orchestrator c7ba7f4.
The classifier fails on the five web vectors until LinkCard.Web lands.
The detection vector test now lists every failing vector, not the first.
*example.com/foo* underlined the closing * as part of the link. A trailing
*, _ or ~ is now dropped once when the same marker directly precedes the
link. Patterns.WEB_URL also backed off before a * or ~ that was followed
by a space, which NSDataDetector keeps, so a path is extended over them
first. All link_detection.json vectors pass.
Nothing depends on it, and web link previews use their own fetcher held
to the Phase 2 fetch rules instead of its jsoup one.
HttpUrl decodes https://ex%61mple.com/ to example.com and would fetch it,
while iOS keeps the escape. Both apps now refuse such a host.
A message whose text is only its web link, once the card resolves, draws
as the card alone in the bubble's corners for its place in the run. The
link text is hidden and the panel fill is the surface. Loading, empty,
failed and tap-to-load states keep the text bubble, and a held answer
that expires and comes back empty returns to it.

"Only the link" shares splitAroundLinkCard's rule through
LinkCard.isAloneIn. The Edited marker follows the bare Flipcash card
placement.
The whole cash card was one combinedClickable with a double-tap
handler, so the Claim pill waited out the double-tap window like the
card body. The pill is now its own click target that does what the card
tap does for a claimable voucher; the rest of the card keeps the
double-tap reaction.
@bmc08gt
bmc08gt force-pushed the feat/web-link-previews branch from 1f13e44 to 6415719 Compare October 8, 2026 23:09
bmc08gt added 12 commits October 8, 2026 19:16
Copies link_metadata.json from orchestrator test/web-address-ranges (7cb34f0), which adds 13 address rows for the D14 ranges. The local range tests now keep only the block edges and Java's mapped-address forms that the fixture rows leave out.
nhl.com's preview image is opaque and almost the chat's own colour, so a bare card's top half read as part of the chat. A bare web card and its placeholder now draw the bare group card's outline (1dp, white at 10%) over the image. A card inside a bubble keeps the bubble as its edge and draws none.
… drop the redirects-shared-4 override

The fixture now caches only the original key when the shared redirect
budget runs out during the home-page fallback, which is what the lookup
already does.
…arries a secret

https://flipcash.com/ showed no preview because every non-card link on a
Flipcash host was kept as plain text. Links on flipcash.com and
www.flipcash.com now fall through to a web card. A first path segment of
login, verify, c or cash never does: the router matches those paths on
any host, so flipcash.com/login/e=<seed> would otherwise be fetched with
the seed in its path. Neither does a link with a fragment. The app hosts
are unchanged.

link_detection.json is synced from orchestrator #45 (b9627fe).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: build-system Gradle, convention plugins, build-logic area: ui Compose UI, theme, components, resources type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant