From f5e7dbf426e061f9c59fb0935ae1e264f2a42fdd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 15:07:44 -0400 Subject: [PATCH 01/39] test(messenger): sync link fixtures and retire aheadOfFixture 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. --- .../internal/link/LinkCardClassifierTest.kt | 45 +- .../src/test/resources/link_detection.json | 285 ++++++++++- .../src/test/resources/link_metadata.json | 460 ++++++++++++++++++ .../shared/chat/ui/LinkDetectionVectorTest.kt | 6 +- .../src/test/resources/link_detection.json | 285 ++++++++++- 5 files changed, 1044 insertions(+), 37 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/resources/link_metadata.json diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt index 287d8d386c..cc7f5d8b1b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt @@ -57,15 +57,6 @@ class LinkCardClassifierTest { override fun dispatch(deepLink: DeepLink) = error("not used in classification tests") } - /** - * Vectors answered ahead of the canonical fixture, by name, with the card kind given. The - * fixture still records person cards as "not in phase 1"; iOS holds the same exception - * (`LinkCardClassifierTests.aheadOfFixture`). Each entry goes when the fixture is updated. - */ - private val aheadOfFixture = mapOf( - "tip-card-by-id" to "user", - ) - private fun fixture(): JSONObject = JSONObject( javaClass.classLoader!! .getResourceAsStream("link_detection.json")!! @@ -95,11 +86,6 @@ class LinkCardClassifierTest { val actual = classifier.firstCard(links) - aheadOfFixture[name]?.let { kind -> - assertEquals(kind, actual?.kindName, "vector `$name` is ahead of the fixture") - continue - } - val expectedCard = vector.optJSONObject("card") if (expectedCard == null) { assertEquals(null, actual, "vector `$name`: ${vector.getString("note")}") @@ -131,6 +117,29 @@ class LinkCardClassifierTest { ) } + "group" -> { + val card = actual as? LinkCard.GroupInvite + assertEquals(expectedCard.getString("url"), card?.url, "vector `$name`: $note") + assertEquals( + LinkCard.GroupInvite.State.Loading, + card?.state, + "vector `$name` must start with its lookup still to do", + ) + } + + "user" -> { + val card = actual as? LinkCard.User + assertEquals(expectedCard.getString("url"), card?.url, "vector `$name`: $note") + assertEquals( + LinkCard.User.State.Loading, + card?.state, + "vector `$name` must start with its lookup still to do", + ) + } + + // LinkCard.Web does not exist yet; compare the url only. + "web" -> assertEquals(expectedCard.getString("url"), actual?.url, "vector `$name`: $note") + else -> error("vector `$name` has an unknown card kind `$kind`") } // The card carries one of the spans the transcript would have underlined, which is @@ -260,14 +269,6 @@ class LinkCardClassifierTest { } } -private val LinkCard.kindName: String - get() = when (this) { - is LinkCard.Cash -> "cash" - is LinkCard.TokenInfo -> "token" - is LinkCard.GroupInvite -> "group" - is LinkCard.User -> "user" - } - /** * `AppRouter`'s bare-host person link. Its reserved list is `internal` to the router module, so a * copy stands in here; `AppRouterTest` holds the real list to the website's pages. diff --git a/apps/flipcash/features/messenger/src/test/resources/link_detection.json b/apps/flipcash/features/messenger/src/test/resources/link_detection.json index 03b94a3086..4d0e2413ca 100644 --- a/apps/flipcash/features/messenger/src/test/resources/link_detection.json +++ b/apps/flipcash/features/messenger/src/test/resources/link_detection.json @@ -1,6 +1,6 @@ { "algorithm": "link-detection", - "note": "Chat link spans and card eligibility. Offsets are UTF-16 code units, half-open. `url` is the resolved URL (https:// prepended when the match had no scheme). `card` is null or the first card-eligible Flipcash link in the message, with its jump wrapper already unwrapped. A match whose following character is non-ASCII is rejected. Behavior parity, not a computation with an external reference.", + "note": "Chat link spans and card eligibility. Offsets are UTF-16 code units, half-open. `url` is the resolved URL (https:// prepended when the match had no scheme). `card` is null or the first card-eligible Flipcash link in the message, with its jump wrapper already unwrapped. `card` is null, the first Flipcash card in the message, or else the first https span whose host is outside cardHosts and eligible (see link_metadata.json hosts), with kind `web`. A card-host span that does not classify never becomes a web card. A match whose following character is non-ASCII is rejected. Behavior parity, not a computation with an external reference.", "cardHosts": [ "app.flipcash.com", "send.flipcash.com", @@ -124,8 +124,11 @@ "url": "https://send.flipcash.com.evil.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" } ], - "card": null, - "note": "A link, because it is one. Not a card: the host is not in cardHosts, and both route classifiers match on path alone, so nothing else would have stopped it." + "card": { + "kind": "web", + "url": "https://send.flipcash.com.evil.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + }, + "note": "Not a Flipcash card: the host is not in cardHosts. It is an outside link, so it gets the web card any outside link gets, and that card shows send.flipcash.com.evil.com as its domain. Showing the real host is the defence." }, { "name": "login-link", @@ -163,8 +166,11 @@ "url": "https://flipcash.com/2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b" } ], - "card": null, - "note": "Card-eligible per the spec, but not in phase 1 (open decision 4). Span only." + "card": { + "kind": "user", + "url": "https://flipcash.com/2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b" + }, + "note": "A person card by user id. Shipped on both apps ahead of this fixture." }, { "name": "token-link", @@ -272,8 +278,11 @@ "url": "https://en.wikipedia.org/wiki/Foo_(bar)" } ], - "card": null, - "note": "A closing bracket the link opened is part of it. The full stop after it is not." + "card": { + "kind": "web", + "url": "https://en.wikipedia.org/wiki/Foo_(bar)" + }, + "note": "A closing bracket the link opened is part of it. The full stop after it is not. An outside https link, so a web card." }, { "name": "trailing-question-mark-kept", @@ -300,6 +309,268 @@ ], "card": null, "note": "A single quote is dropped only when one opened the link. Unopened, it stays, as NSDataDetector keeps it." + }, + { + "name": "bold-wrapped-link", + "text": "*example.com/foo*", + "spans": [ + { + "start": 1, + "end": 16, + "url": "https://example.com/foo" + } + ], + "card": { + "kind": "web", + "url": "https://example.com/foo" + }, + "note": "Formatting markers follow the single-quote rule: a trailing * is dropped when a * directly precedes the link, so *example.com/foo* is bold around a whole link (text-format spec, decision 1)." + }, + { + "name": "italic-wrapped-link", + "text": "_https://flipcash.com/download_", + "spans": [ + { + "start": 1, + "end": 30, + "url": "https://flipcash.com/download" + } + ], + "card": null, + "note": "The same for _." + }, + { + "name": "strike-wrapped-link", + "text": "~flipcash.com/download~", + "spans": [ + { + "start": 1, + "end": 22, + "url": "https://flipcash.com/download" + } + ], + "card": null, + "note": "The same for ~." + }, + { + "name": "bold-wrapped-cash-link", + "text": "*https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3*", + "spans": [ + { + "start": 1, + "end": 55, + "url": "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + } + ], + "card": { + "kind": "cash", + "url": "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + }, + "note": "Trimming the marker keeps it out of the entropy, so the card still forms. The bold around a card link is then not applied (text-format spec, decision 4)." + }, + { + "name": "unopened-trailing-marker-kept", + "text": "see example.com/foo* now", + "spans": [ + { + "start": 4, + "end": 20, + "url": "https://example.com/foo*" + } + ], + "card": { + "kind": "web", + "url": "https://example.com/foo*" + }, + "note": "With no marker before the link, a trailing * stays, as it does for a single quote. Patterns.WEB_URL and NSDataDetector both keep * _ and ~ at the end of a path (NSDataDetector checked on macOS 27)." + }, + { + "name": "mismatched-marker-kept", + "text": "_example.com/foo*", + "spans": [ + { + "start": 1, + "end": 17, + "url": "https://example.com/foo*" + } + ], + "card": { + "kind": "web", + "url": "https://example.com/foo*" + }, + "note": "Only the marker that opened is dropped. A _ before the link does not drop a trailing *." + }, + { + "name": "bare-domain-trailing-underscore", + "text": "_example.com_", + "spans": [], + "card": null, + "note": "A bare domain glued to a trailing _ is not a link. NSDataDetector returns nothing here (macOS 27); Android rejects the match to agree (text-format spec, decision 1)." + }, + { + "name": "bare-domain-underscore-no-opener", + "text": "see example.com_ now", + "spans": [], + "card": null, + "note": "The same without an opening _." + }, + { + "name": "inner-marker-kept", + "text": "*example.com/a*b*", + "spans": [ + { + "start": 1, + "end": 16, + "url": "https://example.com/a*b" + } + ], + "card": { + "kind": "web", + "url": "https://example.com/a*b" + }, + "note": "Only the last character is trimmed; a marker inside the path stays." + }, + { + "name": "group-invite", + "text": "https://app.flipcash.com/chat/6f1c2a9e-3b7d-4e21-8c4a-0d9e7f6b5a13", + "spans": [ + { + "start": 0, + "end": 66, + "url": "https://app.flipcash.com/chat/6f1c2a9e-3b7d-4e21-8c4a-0d9e7f6b5a13" + } + ], + "card": { + "kind": "group", + "url": "https://app.flipcash.com/chat/6f1c2a9e-3b7d-4e21-8c4a-0d9e7f6b5a13" + }, + "note": "A bare /chat/ on a card host is a group card." + }, + { + "name": "web-link", + "text": "read this https://www.example.com/articles/42?ref=chat", + "spans": [ + { + "start": 10, + "end": 54, + "url": "https://www.example.com/articles/42?ref=chat" + } + ], + "card": { + "kind": "web", + "url": "https://www.example.com/articles/42?ref=chat" + }, + "note": "An outside https link is a web card. The card draws under the text; the span stays." + }, + { + "name": "web-link-bare-domain", + "text": "example.com/x", + "spans": [ + { + "start": 0, + "end": 13, + "url": "https://example.com/x" + } + ], + "card": { + "kind": "web", + "url": "https://example.com/x" + }, + "note": "A bare domain resolves with https://, so it can be a web card." + }, + { + "name": "http-link-no-card", + "text": "http://example.com/x", + "spans": [ + { + "start": 0, + "end": 20, + "url": "http://example.com/x" + } + ], + "card": null, + "note": "Neither app permits cleartext traffic. A link, not a card." + }, + { + "name": "flipcash-card-beats-earlier-web-link", + "text": "https://www.example.com/articles/42?ref=chat and https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3", + "spans": [ + { + "start": 0, + "end": 44, + "url": "https://www.example.com/articles/42?ref=chat" + }, + { + "start": 49, + "end": 103, + "url": "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + } + ], + "card": { + "kind": "cash", + "url": "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + }, + "note": "A Flipcash card wins over a web link, wherever the web link sits." + }, + { + "name": "flipcash-host-never-falls-through", + "text": "https://flipcash.com/download", + "spans": [ + { + "start": 0, + "end": 29, + "url": "https://flipcash.com/download" + } + ], + "card": null, + "note": "A card host that does not classify is never a web card." + }, + { + "name": "login-then-web", + "text": "https://app.flipcash.com/login/#/e=KNi8pQr1n5hRU65vKJGge3 https://www.example.com/articles/42?ref=chat", + "spans": [ + { + "start": 0, + "end": 57, + "url": "https://app.flipcash.com/login/#/e=KNi8pQr1n5hRU65vKJGge3" + }, + { + "start": 58, + "end": 102, + "url": "https://www.example.com/articles/42?ref=chat" + } + ], + "card": { + "kind": "web", + "url": "https://www.example.com/articles/42?ref=chat" + }, + "note": "The login link stays text. The next link is judged on its own." + }, + { + "name": "jump-wrapped-outside-link", + "text": "https://jump.flipcash.com/#source=https%3A%2F%2Fexample.com%2Fx", + "spans": [ + { + "start": 0, + "end": 63, + "url": "https://jump.flipcash.com/#source=https%3A%2F%2Fexample.com%2Fx" + } + ], + "card": null, + "note": "A jump wrapper never becomes a web card, whatever it wraps." + }, + { + "name": "ip-literal-no-card", + "text": "https://192.168.1.10/admin", + "spans": [ + { + "start": 0, + "end": 26, + "url": "https://192.168.1.10/admin" + } + ], + "card": null, + "note": "An IP literal host is never fetched." } ] } diff --git a/apps/flipcash/features/messenger/src/test/resources/link_metadata.json b/apps/flipcash/features/messenger/src/test/resources/link_metadata.json new file mode 100644 index 0000000000..d9a00ab0de --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/resources/link_metadata.json @@ -0,0 +1,460 @@ +{ + "algorithm": "link-metadata", + "note": "Generic web link previews. pages: HTML as fetched (decode as UTF-8) plus the URL after redirects, to the card's metadata or null. Only the head is read; og:title, else , is required; og:description, else meta description; og:image resolved against finalUrl and dropped unless https on an eligible host. host is finalUrl's host minus one leading www.; og:url and og:site_name are never read. hosts: whether a host may be fetched, checked on the first request and on every redirect. addresses: whether a resolved address may be connected to; both apps connect only to an address that passed, so DNS rebinding is stopped on both. cacheKeys: the persisted key is web:<key>. Behavior parity, not a computation with an external reference.", + "limits": { + "maxBodyBytes": 524288, + "maxImageBytes": 2097152, + "maxRedirects": 3, + "timeoutSeconds": 5, + "maxConcurrent": 4, + "resolvedTtlHours": 168, + "emptyTtlHours": 24 + }, + "pages": [ + { + "name": "og-basic", + "finalUrl": "https://www.example.com/a/b", + "html": "<!doctype html><html><head><meta property=\"og:title\" content=\"Hello\"><meta property=\"og:description\" content=\"World\"><meta property=\"og:image\" content=\"https://cdn.example.com/i.png\"></head><body><p>body</p></body></html>", + "expect": { + "title": "Hello", + "description": "World", + "imageUrl": "https://cdn.example.com/i.png", + "host": "example.com" + }, + "note": "The three tags a card uses. host drops one leading www." + }, + { + "name": "title-fallback", + "finalUrl": "https://www.example.com/a/b", + "html": "<!doctype html><html><head><title> Plain\n title

body

", + "expect": { + "title": "Plain title", + "description": "Desc", + "imageUrl": null, + "host": "example.com" + }, + "note": "No og:title: text, whitespace collapsed. No og:description: meta name=description." + }, + { + "name": "og-beats-title", + "finalUrl": "https://www.example.com/a/b", + "html": "<!doctype html><html><head><title>Fallback

body

", + "expect": { + "title": "Preferred", + "description": null, + "imageUrl": null, + "host": "example.com" + }, + "note": "og:title wins regardless of order." + }, + { + "name": "empty-og-falls-back", + "finalUrl": "https://www.example.com/a/b", + "html": "Real

body

", + "expect": { + "title": "Real", + "description": null, + "imageUrl": null, + "host": "example.com" + }, + "note": "A value empty after trimming is absent, so the fallback applies." + }, + { + "name": "first-og-wins", + "finalUrl": "https://www.example.com/a/b", + "html": "

body

", + "expect": { + "title": "One", + "description": null, + "imageUrl": "https://www.example.com/1.png", + "host": "example.com" + }, + "note": "First non-empty wins for every field." + }, + { + "name": "relative-image", + "finalUrl": "https://www.example.com/a/b", + "html": "

body

", + "expect": { + "title": "T", + "description": null, + "imageUrl": "https://www.example.com/img/x.png", + "host": "example.com" + }, + "note": "Resolved against finalUrl with RFC 3986 rules." + }, + { + "name": "protocol-relative-image", + "finalUrl": "https://www.example.com/a/b", + "html": "

body

", + "expect": { + "title": "T", + "description": null, + "imageUrl": "https://cdn.example.com/x.png", + "host": "example.com" + }, + "note": "Takes finalUrl's scheme." + }, + { + "name": "http-image-dropped", + "finalUrl": "https://www.example.com/a/b", + "html": "

body

", + "expect": { + "title": "T", + "description": null, + "imageUrl": null, + "host": "example.com" + }, + "note": "Cleartext is not permitted. The card draws without an image." + }, + { + "name": "private-image-dropped", + "finalUrl": "https://www.example.com/a/b", + "html": "

body

", + "expect": { + "title": "T", + "description": null, + "imageUrl": null, + "host": "example.com" + }, + "note": "An image on an ineligible host is dropped, not fetched." + }, + { + "name": "localhost-image-dropped", + "finalUrl": "https://www.example.com/a/b", + "html": "

body

", + "expect": { + "title": "T", + "description": null, + "imageUrl": null, + "host": "example.com" + }, + "note": "Same rule, by name." + }, + { + "name": "entities", + "finalUrl": "https://www.example.com/a/b", + "html": "

body

", + "expect": { + "title": "Tom & Jerry — \"live\" — it's 'on' now ©", + "description": null, + "imageUrl": null, + "host": "example.com" + }, + "note": "The decoded set is fixed. NBSP becomes a collapsed space. An unknown named entity stays as written." + }, + { + "name": "single-and-unquoted-attributes", + "finalUrl": "https://www.example.com/a/b", + "html": "", + "expect": { + "title": "Quoted", + "description": "Bare", + "imageUrl": null, + "host": "example.com" + }, + "note": "Tag and attribute names and property values are case-insensitive. Values may be single-, double- or un-quoted." + }, + { + "name": "name-attribute-og", + "finalUrl": "https://www.example.com/a/b", + "html": "

body

", + "expect": { + "title": "Via name", + "description": null, + "imageUrl": null, + "host": "example.com" + }, + "note": "Some sites put og: tags in name=. property or name, either matches." + }, + { + "name": "body-tags-ignored", + "finalUrl": "https://www.example.com/a/b", + "html": "Head", + "expect": { + "title": "Head", + "description": null, + "imageUrl": null, + "host": "example.com" + }, + "note": "Only the head is read." + }, + { + "name": "no-head-tag", + "finalUrl": "https://www.example.com/a/b", + "html": "

x

", + "expect": { + "title": "Headless", + "description": null, + "imageUrl": null, + "host": "example.com" + }, + "note": "No : everything before counts." + }, + { + "name": "site-name-and-og-url-ignored", + "finalUrl": "https://evil.example.net/x", + "html": "

body

", + "expect": { + "title": "Sign in", + "description": null, + "imageUrl": null, + "host": "evil.example.net" + }, + "note": "The domain shown is finalUrl's host. Page-written identity is never read." + }, + { + "name": "host-after-redirect", + "finalUrl": "https://news.example.org/story", + "html": "

body

", + "expect": { + "title": "Story", + "description": null, + "imageUrl": null, + "host": "news.example.org" + }, + "note": "finalUrl is the URL after redirects. Only one leading www. is dropped; other subdomains stay." + }, + { + "name": "no-title-no-card", + "finalUrl": "https://www.example.com/a/b", + "html": "

body

", + "expect": null, + "note": "A card needs a title." + }, + { + "name": "empty-document", + "finalUrl": "https://www.example.com/a/b", + "html": "", + "expect": null, + "note": "Nothing to read." + }, + { + "name": "not-really-html", + "finalUrl": "https://www.example.com/a/b", + "html": "{\"title\":\"json\"}", + "expect": null, + "note": "No tags at all." + } + ], + "hosts": [ + { + "host": "example.com", + "eligible": true, + "note": "" + }, + { + "host": "www.example.com", + "eligible": true, + "note": "" + }, + { + "host": "send.flipcash.com.evil.com", + "eligible": true, + "note": "Eligible to fetch. It is an outside host." + }, + { + "host": "xn--80ak6aa92e.com", + "eligible": true, + "note": "Punycode is a name like any other." + }, + { + "host": "localhost", + "eligible": false, + "note": "" + }, + { + "host": "LOCALHOST", + "eligible": false, + "note": "Compared lowercased." + }, + { + "host": "printer.local", + "eligible": false, + "note": "mDNS." + }, + { + "host": "router.internal", + "eligible": false, + "note": "" + }, + { + "host": "dev.localhost", + "eligible": false, + "note": "" + }, + { + "host": "intranet", + "eligible": false, + "note": "Single label." + }, + { + "host": "127.0.0.1", + "eligible": false, + "note": "Every IP literal, public or not." + }, + { + "host": "192.168.1.10", + "eligible": false, + "note": "" + }, + { + "host": "8.8.8.8", + "eligible": false, + "note": "Public, and still a literal." + }, + { + "host": "[::1]", + "eligible": false, + "note": "Bracketed IPv6 literal." + }, + { + "host": "[2606:4700::1111]", + "eligible": false, + "note": "" + } + ], + "cacheKeys": [ + { + "url": "https://example.com/a", + "key": "https://example.com/a" + }, + { + "url": "HTTPS://Example.COM/A?B=1", + "key": "https://example.com/A?B=1" + }, + { + "url": "https://example.com/a#section", + "key": "https://example.com/a" + }, + { + "url": "https://example.com:443/a", + "key": "https://example.com/a" + }, + { + "url": "https://example.com:8443/a", + "key": "https://example.com:8443/a" + }, + { + "url": "https://example.com/a?x=1&y=2#f", + "key": "https://example.com/a?x=1&y=2" + } + ], + "addresses": [ + { + "address": "93.184.215.14", + "public": true, + "note": "public IPv4" + }, + { + "address": "2606:2800:21f:cb07:6820:80da:af6b:8b2c", + "public": true, + "note": "public IPv6" + }, + { + "address": "0.1.2.3", + "public": false, + "note": "0.0.0.0/8" + }, + { + "address": "10.1.2.3", + "public": false, + "note": "10/8" + }, + { + "address": "100.64.0.1", + "public": false, + "note": "CGNAT 100.64/10, low end" + }, + { + "address": "100.127.255.254", + "public": false, + "note": "CGNAT 100.64/10, high end" + }, + { + "address": "100.128.0.1", + "public": true, + "note": "just past CGNAT" + }, + { + "address": "127.0.0.1", + "public": false, + "note": "loopback" + }, + { + "address": "169.254.169.254", + "public": false, + "note": "link-local; cloud metadata endpoint" + }, + { + "address": "172.16.0.1", + "public": false, + "note": "172.16/12, low end" + }, + { + "address": "172.31.255.254", + "public": false, + "note": "172.16/12, high end" + }, + { + "address": "172.32.0.1", + "public": true, + "note": "just past 172.16/12" + }, + { + "address": "192.168.1.1", + "public": false, + "note": "192.168/16" + }, + { + "address": "224.0.0.1", + "public": false, + "note": "multicast" + }, + { + "address": "255.255.255.255", + "public": false, + "note": "broadcast" + }, + { + "address": "::", + "public": false, + "note": "IPv6 unspecified" + }, + { + "address": "::1", + "public": false, + "note": "IPv6 loopback" + }, + { + "address": "fd12:3456::1", + "public": false, + "note": "unique-local fc00::/7" + }, + { + "address": "fe80::1", + "public": false, + "note": "link-local" + }, + { + "address": "fec0::1", + "public": false, + "note": "deprecated site-local" + }, + { + "address": "ff02::1", + "public": false, + "note": "multicast" + }, + { + "address": "::ffff:10.0.0.1", + "public": false, + "note": "IPv4-mapped, judged by 10.0.0.1" + }, + { + "address": "::ffff:93.184.215.14", + "public": true, + "note": "IPv4-mapped, judged by its public IPv4" + } + ] +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkDetectionVectorTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkDetectionVectorTest.kt index 67736edecc..d2ad686118 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkDetectionVectorTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkDetectionVectorTest.kt @@ -31,6 +31,7 @@ class LinkDetectionVectorTest { @Test fun `spans match the cross-platform vectors`() { val annotator = UrlAnnotator(SpanStyle()) + val failures = mutableListOf() for (vector in vectors()) { val name = vector.getString("name") val text = vector.getString("text") @@ -50,7 +51,10 @@ class LinkDetectionVectorTest { Triple(span.getInt("start"), span.getInt("end"), span.getString("url")) } - assertEquals(expected, actual, "vector `$name`: ${vector.getString("note")}") + if (expected != actual) { + failures += "vector `$name`: expected $expected but was $actual. ${vector.getString("note")}" + } } + assertEquals(emptyList(), failures, failures.joinToString("\n")) } } diff --git a/apps/flipcash/shared/chat-ui/src/test/resources/link_detection.json b/apps/flipcash/shared/chat-ui/src/test/resources/link_detection.json index 03b94a3086..4d0e2413ca 100644 --- a/apps/flipcash/shared/chat-ui/src/test/resources/link_detection.json +++ b/apps/flipcash/shared/chat-ui/src/test/resources/link_detection.json @@ -1,6 +1,6 @@ { "algorithm": "link-detection", - "note": "Chat link spans and card eligibility. Offsets are UTF-16 code units, half-open. `url` is the resolved URL (https:// prepended when the match had no scheme). `card` is null or the first card-eligible Flipcash link in the message, with its jump wrapper already unwrapped. A match whose following character is non-ASCII is rejected. Behavior parity, not a computation with an external reference.", + "note": "Chat link spans and card eligibility. Offsets are UTF-16 code units, half-open. `url` is the resolved URL (https:// prepended when the match had no scheme). `card` is null or the first card-eligible Flipcash link in the message, with its jump wrapper already unwrapped. `card` is null, the first Flipcash card in the message, or else the first https span whose host is outside cardHosts and eligible (see link_metadata.json hosts), with kind `web`. A card-host span that does not classify never becomes a web card. A match whose following character is non-ASCII is rejected. Behavior parity, not a computation with an external reference.", "cardHosts": [ "app.flipcash.com", "send.flipcash.com", @@ -124,8 +124,11 @@ "url": "https://send.flipcash.com.evil.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" } ], - "card": null, - "note": "A link, because it is one. Not a card: the host is not in cardHosts, and both route classifiers match on path alone, so nothing else would have stopped it." + "card": { + "kind": "web", + "url": "https://send.flipcash.com.evil.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + }, + "note": "Not a Flipcash card: the host is not in cardHosts. It is an outside link, so it gets the web card any outside link gets, and that card shows send.flipcash.com.evil.com as its domain. Showing the real host is the defence." }, { "name": "login-link", @@ -163,8 +166,11 @@ "url": "https://flipcash.com/2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b" } ], - "card": null, - "note": "Card-eligible per the spec, but not in phase 1 (open decision 4). Span only." + "card": { + "kind": "user", + "url": "https://flipcash.com/2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b" + }, + "note": "A person card by user id. Shipped on both apps ahead of this fixture." }, { "name": "token-link", @@ -272,8 +278,11 @@ "url": "https://en.wikipedia.org/wiki/Foo_(bar)" } ], - "card": null, - "note": "A closing bracket the link opened is part of it. The full stop after it is not." + "card": { + "kind": "web", + "url": "https://en.wikipedia.org/wiki/Foo_(bar)" + }, + "note": "A closing bracket the link opened is part of it. The full stop after it is not. An outside https link, so a web card." }, { "name": "trailing-question-mark-kept", @@ -300,6 +309,268 @@ ], "card": null, "note": "A single quote is dropped only when one opened the link. Unopened, it stays, as NSDataDetector keeps it." + }, + { + "name": "bold-wrapped-link", + "text": "*example.com/foo*", + "spans": [ + { + "start": 1, + "end": 16, + "url": "https://example.com/foo" + } + ], + "card": { + "kind": "web", + "url": "https://example.com/foo" + }, + "note": "Formatting markers follow the single-quote rule: a trailing * is dropped when a * directly precedes the link, so *example.com/foo* is bold around a whole link (text-format spec, decision 1)." + }, + { + "name": "italic-wrapped-link", + "text": "_https://flipcash.com/download_", + "spans": [ + { + "start": 1, + "end": 30, + "url": "https://flipcash.com/download" + } + ], + "card": null, + "note": "The same for _." + }, + { + "name": "strike-wrapped-link", + "text": "~flipcash.com/download~", + "spans": [ + { + "start": 1, + "end": 22, + "url": "https://flipcash.com/download" + } + ], + "card": null, + "note": "The same for ~." + }, + { + "name": "bold-wrapped-cash-link", + "text": "*https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3*", + "spans": [ + { + "start": 1, + "end": 55, + "url": "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + } + ], + "card": { + "kind": "cash", + "url": "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + }, + "note": "Trimming the marker keeps it out of the entropy, so the card still forms. The bold around a card link is then not applied (text-format spec, decision 4)." + }, + { + "name": "unopened-trailing-marker-kept", + "text": "see example.com/foo* now", + "spans": [ + { + "start": 4, + "end": 20, + "url": "https://example.com/foo*" + } + ], + "card": { + "kind": "web", + "url": "https://example.com/foo*" + }, + "note": "With no marker before the link, a trailing * stays, as it does for a single quote. Patterns.WEB_URL and NSDataDetector both keep * _ and ~ at the end of a path (NSDataDetector checked on macOS 27)." + }, + { + "name": "mismatched-marker-kept", + "text": "_example.com/foo*", + "spans": [ + { + "start": 1, + "end": 17, + "url": "https://example.com/foo*" + } + ], + "card": { + "kind": "web", + "url": "https://example.com/foo*" + }, + "note": "Only the marker that opened is dropped. A _ before the link does not drop a trailing *." + }, + { + "name": "bare-domain-trailing-underscore", + "text": "_example.com_", + "spans": [], + "card": null, + "note": "A bare domain glued to a trailing _ is not a link. NSDataDetector returns nothing here (macOS 27); Android rejects the match to agree (text-format spec, decision 1)." + }, + { + "name": "bare-domain-underscore-no-opener", + "text": "see example.com_ now", + "spans": [], + "card": null, + "note": "The same without an opening _." + }, + { + "name": "inner-marker-kept", + "text": "*example.com/a*b*", + "spans": [ + { + "start": 1, + "end": 16, + "url": "https://example.com/a*b" + } + ], + "card": { + "kind": "web", + "url": "https://example.com/a*b" + }, + "note": "Only the last character is trimmed; a marker inside the path stays." + }, + { + "name": "group-invite", + "text": "https://app.flipcash.com/chat/6f1c2a9e-3b7d-4e21-8c4a-0d9e7f6b5a13", + "spans": [ + { + "start": 0, + "end": 66, + "url": "https://app.flipcash.com/chat/6f1c2a9e-3b7d-4e21-8c4a-0d9e7f6b5a13" + } + ], + "card": { + "kind": "group", + "url": "https://app.flipcash.com/chat/6f1c2a9e-3b7d-4e21-8c4a-0d9e7f6b5a13" + }, + "note": "A bare /chat/ on a card host is a group card." + }, + { + "name": "web-link", + "text": "read this https://www.example.com/articles/42?ref=chat", + "spans": [ + { + "start": 10, + "end": 54, + "url": "https://www.example.com/articles/42?ref=chat" + } + ], + "card": { + "kind": "web", + "url": "https://www.example.com/articles/42?ref=chat" + }, + "note": "An outside https link is a web card. The card draws under the text; the span stays." + }, + { + "name": "web-link-bare-domain", + "text": "example.com/x", + "spans": [ + { + "start": 0, + "end": 13, + "url": "https://example.com/x" + } + ], + "card": { + "kind": "web", + "url": "https://example.com/x" + }, + "note": "A bare domain resolves with https://, so it can be a web card." + }, + { + "name": "http-link-no-card", + "text": "http://example.com/x", + "spans": [ + { + "start": 0, + "end": 20, + "url": "http://example.com/x" + } + ], + "card": null, + "note": "Neither app permits cleartext traffic. A link, not a card." + }, + { + "name": "flipcash-card-beats-earlier-web-link", + "text": "https://www.example.com/articles/42?ref=chat and https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3", + "spans": [ + { + "start": 0, + "end": 44, + "url": "https://www.example.com/articles/42?ref=chat" + }, + { + "start": 49, + "end": 103, + "url": "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + } + ], + "card": { + "kind": "cash", + "url": "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + }, + "note": "A Flipcash card wins over a web link, wherever the web link sits." + }, + { + "name": "flipcash-host-never-falls-through", + "text": "https://flipcash.com/download", + "spans": [ + { + "start": 0, + "end": 29, + "url": "https://flipcash.com/download" + } + ], + "card": null, + "note": "A card host that does not classify is never a web card." + }, + { + "name": "login-then-web", + "text": "https://app.flipcash.com/login/#/e=KNi8pQr1n5hRU65vKJGge3 https://www.example.com/articles/42?ref=chat", + "spans": [ + { + "start": 0, + "end": 57, + "url": "https://app.flipcash.com/login/#/e=KNi8pQr1n5hRU65vKJGge3" + }, + { + "start": 58, + "end": 102, + "url": "https://www.example.com/articles/42?ref=chat" + } + ], + "card": { + "kind": "web", + "url": "https://www.example.com/articles/42?ref=chat" + }, + "note": "The login link stays text. The next link is judged on its own." + }, + { + "name": "jump-wrapped-outside-link", + "text": "https://jump.flipcash.com/#source=https%3A%2F%2Fexample.com%2Fx", + "spans": [ + { + "start": 0, + "end": 63, + "url": "https://jump.flipcash.com/#source=https%3A%2F%2Fexample.com%2Fx" + } + ], + "card": null, + "note": "A jump wrapper never becomes a web card, whatever it wraps." + }, + { + "name": "ip-literal-no-card", + "text": "https://192.168.1.10/admin", + "spans": [ + { + "start": 0, + "end": 26, + "url": "https://192.168.1.10/admin" + } + ], + "card": null, + "note": "An IP literal host is never fetched." } ] } From 7e5a47a1776f0a52abc0cac7baf58da761201184 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 15:07:45 -0400 Subject: [PATCH 02/39] fix(chat): drop a closing format marker from a wrapped link *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. --- .../flipcash/shared/chat/ui/LinkDetection.kt | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt index a37961ced3..ae6e92cf4a 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt @@ -23,20 +23,55 @@ data class DetectedUrl( fun detectUrls(text: String): List = buildList { val matcher = Patterns.WEB_URL.matcher(text) while (matcher.find()) { - val match = matcher.group() ?: continue + val start = matcher.start() + val end = text.extendOverMarkers(matcher.group() ?: continue, start, matcher.end()) + val match = text.substring(start, end) if (!authorityIsAscii(match)) continue - if (!endsOnAsciiBoundary(text, matcher.end())) continue - val url = match.dropTrailingPunctuation(openedBy = text.getOrNull(matcher.start() - 1)) + if (!endsOnAsciiBoundary(text, end)) continue + val openedBy = text.getOrNull(start - 1) + val url = match.dropClosingMarker(openedBy).dropTrailingPunctuation(openedBy) add( DetectedUrl( - start = matcher.start(), - end = matcher.start() + url.length, + start = start, + end = start + url.length, url = withLowercaseScheme(url), ), ) } } +/** + * The end of a link [match] that `Patterns.WEB_URL` cut short before a run of `*` or `~`. + * + * The pattern ends on a word boundary, so in `example.com/foo* now` it backs off before the `*`, + * while `NSDataDetector` keeps it. Only a path is extended: a marker after a bare host is not + * part of the link on either platform. + */ +private fun String.extendOverMarkers(match: String, start: Int, end: Int): Int { + if (!match.hasPath()) return end + var e = end + while (e < length && this[e] in EXTENDABLE_MARKERS) e++ + return e +} + +private fun String.hasPath(): Boolean { + val schemeEnd = indexOf("://") + return indexOf('/', startIndex = if (schemeEnd < 0) 0 else schemeEnd + 3) >= 0 +} + +/** + * [this] without a closing formatting marker. A trailing `*`, `_` or `~` is dropped once, and only + * when the same marker directly precedes the link, so `*example.com/foo*` is bold around a whole + * link. This is the single-quote rule below applied to markers (text-format spec, decision 1). + */ +private fun String.dropClosingMarker(openedBy: Char?): String { + val last = lastOrNull() ?: return this + return if (last in FORMAT_MARKERS && last == openedBy) dropLast(1) else this +} + +private const val FORMAT_MARKERS = "*_~" +private const val EXTENDABLE_MARKERS = "*~" + /** * [this] without the punctuation that ends the sentence around it, the way iOS's `NSDataDetector` * reads a link. From c8703384c437043b185ca88bea6ec8d27c3130cf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 15:08:16 -0400 Subject: [PATCH 03/39] chore(libs): delete unused opengraph module Nothing depends on it, and web link previews use their own fetcher held to the Phase 2 fetch rules instead of its jsoup one. --- docs/architecture/05-persistence.md | 7 +- libs/opengraph/.gitignore | 2 - libs/opengraph/build.gradle.kts | 18 --- .../libs/opengraph/OpenGraphCacheProvider.kt | 57 --------- .../getcode/libs/opengraph/OpenGraphParser.kt | 89 -------------- .../libs/opengraph/cache/CacheProvider.kt | 8 -- .../opengraph/callback/OpenGraphCallback.kt | 8 -- .../libs/opengraph/fetcher/JsoupFetcher.kt | 111 ------------------ .../libs/opengraph/model/OpenGraphResult.kt | 13 -- .../com/getcode/libs/opengraph/model/Proxy.kt | 6 - settings.gradle.kts | 1 - ui/components/build.gradle.kts | 1 - 12 files changed, 3 insertions(+), 318 deletions(-) delete mode 100644 libs/opengraph/.gitignore delete mode 100644 libs/opengraph/build.gradle.kts delete mode 100644 libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphCacheProvider.kt delete mode 100644 libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphParser.kt delete mode 100644 libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/cache/CacheProvider.kt delete mode 100644 libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/callback/OpenGraphCallback.kt delete mode 100644 libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/fetcher/JsoupFetcher.kt delete mode 100644 libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/OpenGraphResult.kt delete mode 100644 libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/Proxy.kt diff --git a/docs/architecture/05-persistence.md b/docs/architecture/05-persistence.md index 3c39cc9bac..ef7964df9b 100644 --- a/docs/architecture/05-persistence.md +++ b/docs/architecture/05-persistence.md @@ -94,10 +94,9 @@ from the local copy. This gives offline reads and a single source of truth. Preferences and small caches use Jetpack **DataStore** rather than the database. A representative example is -[`OpenGraphCacheProvider`](../../libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphCacheProvider.kt), -which caches link-preview metadata via `PreferenceDataStoreFactory.create(...)` with -a corruption handler and JSON-serialized values. User flags and similar small state -follow the same pattern. +[`UserFlagsCoordinator`](../../apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt), +which stores per-user flags via `PreferenceDataStoreFactory.create(...)` with +a corruption handler. Other small state follows the same pattern. ## Why this matters diff --git a/libs/opengraph/.gitignore b/libs/opengraph/.gitignore deleted file mode 100644 index 9f2a078806..0000000000 --- a/libs/opengraph/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -build/ -.gradle/ diff --git a/libs/opengraph/build.gradle.kts b/libs/opengraph/build.gradle.kts deleted file mode 100644 index 39497bbd29..0000000000 --- a/libs/opengraph/build.gradle.kts +++ /dev/null @@ -1,18 +0,0 @@ -plugins { - alias(libs.plugins.flipcash.android.library.compose) -} - -android { - namespace = "${Gradle.codeNamespace}.libs.opengraph" -} - -dependencies { - implementation("org.jsoup:jsoup:1.23.2") - implementation(project(":libs:coroutines")) - implementation(project(":libs:encryption:utils")) - - implementation(libs.bundles.kotlinx.serialization) - implementation(libs.bundles.hilt) - - implementation(libs.androidx.datastore) -} diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphCacheProvider.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphCacheProvider.kt deleted file mode 100644 index a407964d15..0000000000 --- a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphCacheProvider.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.getcode.libs.opengraph - -import android.content.Context -import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStoreFile -import com.flipcash.libs.coroutines.DispatcherProvider -import com.getcode.libs.opengraph.cache.CacheProvider -import com.getcode.libs.opengraph.model.OpenGraphResult -import com.getcode.utils.base64 -import com.getcode.utils.decodeBase64 -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import javax.inject.Inject - -class OpenGraphCacheProvider @Inject constructor( - @ApplicationContext - context: Context, - private val dispatchers: DispatcherProvider, -): CacheProvider { - - private val dataScope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatchers.IO) - - private val storage = PreferenceDataStoreFactory.create( - corruptionHandler = ReplaceFileCorruptionHandler( - produceNewData = { emptyPreferences() } - ), - migrations = listOf(), - scope = dataScope, - produceFile = { context.preferencesDataStoreFile("open-graph") } - ) - - override suspend fun get(url: String): OpenGraphResult? { - return storage.data.map { prefs -> - val result = prefs[stringPreferencesKey(url)] ?: return@map null - Json.decodeFromString(result.decodeBase64().decodeToString()) - }.firstOrNull() - } - - override suspend fun set(openGraphResult: OpenGraphResult, url: String) { - dataScope.launch { - storage.edit { prefs -> - prefs[stringPreferencesKey(url)] = Json.encodeToString(openGraphResult).encodeToByteArray().base64 - } - } - } -} \ No newline at end of file diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphParser.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphParser.kt deleted file mode 100644 index b36c2246fa..0000000000 --- a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphParser.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.getcode.libs.opengraph - -import androidx.compose.runtime.staticCompositionLocalOf -import com.getcode.libs.opengraph.cache.CacheProvider -import com.getcode.libs.opengraph.callback.OpenGraphCallback -import com.getcode.libs.opengraph.fetcher.JsoupFetcher -import com.getcode.libs.opengraph.fetcher.checkNullParserResult -import com.getcode.libs.opengraph.model.OpenGraphResult -import com.getcode.libs.opengraph.model.Proxy -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject -import kotlin.coroutines.CoroutineContext - -val LocalOpenGraphParser = staticCompositionLocalOf { null } - -class OpenGraphParser @Inject constructor( - private val showNullOnEmpty: Boolean = false, - private val cacheProvider: CacheProvider? = null, - timeout: Int? = null, - proxy: Proxy? = null, - maxBodySize: Int? = null -) { - private val fetcher = JsoupFetcher(timeout, proxy, maxBodySize) - - fun parse(url: String, callback: OpenGraphCallback) { - ParseLink(url, callback).parse() - } - - inner class ParseLink(private val url: String, private val callback: OpenGraphCallback) : CoroutineScope { - private val job: Job = Job() - override val coroutineContext: CoroutineContext - get() = Dispatchers.Main + job - - fun parse() = launch { - val result = fetchContent(url, callback) - result?.let { - callback.onResponse(it) - } - } - } - private suspend fun fetchContent(url: String, callback: OpenGraphCallback) = withContext(Dispatchers.IO) { - var validatedUrl = url - if (!validatedUrl.contains("http")) { - validatedUrl = "http://$validatedUrl" - } - - cacheProvider?.get(url)?.let { - return@withContext it - } - - var openGraphResult: OpenGraphResult? = null - AGENTS.forEach { - openGraphResult = fetcher.call(validatedUrl, it) - val isResultNull = checkNullParserResult(openGraphResult) - if (!isResultNull) { - openGraphResult?.let { cacheProvider?.set(it, url) } - return@withContext openGraphResult - } - } - - if (checkNullParserResult(openGraphResult)) { - launch(Dispatchers.Main) { - callback.onError("Null or empty response from the server") - } - return@withContext null - } - - openGraphResult?.let { cacheProvider?.set(it, url) } - - return@withContext openGraphResult - } - - companion object { - private val AGENTS = arrayOf( - "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)", - "Mozilla", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36", - "WhatsApp/2.19.81 A", - "facebookexternalhit/1.1", - "facebookcatalog/1.0" - ) - } - -} \ No newline at end of file diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/cache/CacheProvider.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/cache/CacheProvider.kt deleted file mode 100644 index 4ea4691fdc..0000000000 --- a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/cache/CacheProvider.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.getcode.libs.opengraph.cache - -import com.getcode.libs.opengraph.model.OpenGraphResult - -interface CacheProvider { - suspend fun get(url: String): OpenGraphResult? - suspend fun set(openGraphResult: OpenGraphResult, url: String) -} \ No newline at end of file diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/callback/OpenGraphCallback.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/callback/OpenGraphCallback.kt deleted file mode 100644 index 8cda6d74dd..0000000000 --- a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/callback/OpenGraphCallback.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.getcode.libs.opengraph.callback - -import com.getcode.libs.opengraph.model.OpenGraphResult - -interface OpenGraphCallback { - fun onResponse(result: OpenGraphResult) - fun onError(error: String) -} \ No newline at end of file diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/fetcher/JsoupFetcher.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/fetcher/JsoupFetcher.kt deleted file mode 100644 index 63a83dca3a..0000000000 --- a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/fetcher/JsoupFetcher.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.getcode.libs.opengraph.fetcher - -import com.getcode.libs.opengraph.model.OpenGraphResult -import com.getcode.libs.opengraph.model.Proxy -import org.jsoup.Jsoup -import java.net.URI -import java.net.URL - -class JsoupFetcher( - private val timeout: Int? = DEFAULT_TIMEOUT, - private val jsoupProxy: Proxy? = null, - private val maxBodySize: Int? = null -) { - fun call(url: String, agent: String): OpenGraphResult? { - var image: String? = null - var description: String? = null - var title: String? = null - var resultUrl: String? = null - var siteName: String? = null - var type: String? = null - - return try { - val connection = Jsoup.connect(url) - .ignoreContentType(true) - .userAgent(agent) - .referrer(REFERRER) - .timeout(timeout ?: DEFAULT_TIMEOUT) - .followRedirects(true) - - jsoupProxy?.let { connection.proxy(it.host, it.port) } - maxBodySize?.let { connection.maxBodySize(it) } - - val response = connection.execute() - val doc = response.parse() - val ogTags = doc.select(DOC_SELECT_OGTAGS) - - ogTags.forEach { tag -> - when (tag.attr(PROPERTY)) { - OG_IMAGE -> { - image = tag.attr(OPEN_GRAPH_KEY) - } - OG_DESCRIPTION -> { - description = tag.attr(OPEN_GRAPH_KEY) - } - - OG_URL -> { - resultUrl = tag.attr(OPEN_GRAPH_KEY) - } - - OG_TITLE -> { - title = tag.attr(OPEN_GRAPH_KEY) - } - - OG_SITE_NAME -> { - siteName = tag.attr(OPEN_GRAPH_KEY) - } - - OG_TYPE -> { - type = tag.attr(OPEN_GRAPH_KEY) - } - } - } - - if (title.isNullOrEmpty()) { - title = doc.title() - } - - if (description.isNullOrEmpty()) { - val docSelection = doc.select(DOC_SELECT_DESCRIPTION) - description = docSelection.firstOrNull()?.attr("content").orEmpty() - } - - if (resultUrl.isNullOrEmpty()) { - resultUrl = getBaseUrl(url) - } - - OpenGraphResult(title, description, resultUrl, image, siteName, type) - } catch (e: Exception) { - e.printStackTrace() - null - } - } - - companion object { - private const val REFERRER = "http://flipchat.xyz" - private const val DEFAULT_TIMEOUT = 60000 - - private const val DOC_SELECT_OGTAGS = "meta[property^=og:]" - private const val DOC_SELECT_DESCRIPTION = "meta[name=description]" - - private const val OPEN_GRAPH_KEY = "content" - private const val PROPERTY = "property" - - private const val OG_IMAGE = "og:image" - private const val OG_DESCRIPTION = "og:description" - private const val OG_URL = "og:url" - private const val OG_TITLE = "og:title" - private const val OG_SITE_NAME = "og:site_name" - private const val OG_TYPE = "og:type" - } -} - -fun checkNullParserResult(openGraphResult: OpenGraphResult?): Boolean { - return (openGraphResult?.title.isNullOrEmpty() || openGraphResult?.title == "null") && - (openGraphResult?.description.isNullOrEmpty() || openGraphResult?.description == "null") -} - -private fun getBaseUrl(urlString: String): String { - val url: URL = URI.create(urlString).toURL() - return url.protocol.toString() + "://" + url.authority + "/" -} diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/OpenGraphResult.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/OpenGraphResult.kt deleted file mode 100644 index f692a4eb1e..0000000000 --- a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/OpenGraphResult.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.getcode.libs.opengraph.model - -import kotlinx.serialization.Serializable - -@Serializable -data class OpenGraphResult( - val title: String? = null, - val description: String? = null, - val url: String? = null, - val image: String? = null, - val siteName: String? = null, - val type: String? = null -) diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/Proxy.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/Proxy.kt deleted file mode 100644 index cacf99e61a..0000000000 --- a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/Proxy.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.getcode.libs.opengraph.model - -data class Proxy( - val host: String, - val port: Int, -) diff --git a/settings.gradle.kts b/settings.gradle.kts index 0e0b9aa37d..764b0c5ac3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -224,7 +224,6 @@ include( ":libs:logging", ":libs:messaging", ":libs:models", - ":libs:opengraph", ":libs:network:coinbase:onramp", diff --git a/ui/components/build.gradle.kts b/ui/components/build.gradle.kts index dd7670c30a..b66710c0a1 100644 --- a/ui/components/build.gradle.kts +++ b/ui/components/build.gradle.kts @@ -23,7 +23,6 @@ dependencies { implementation(project(":libs:messaging")) implementation(project(":libs:models")) implementation(project(":libs:network:connectivity:public")) - implementation(project(":libs:opengraph")) implementation(project(":libs:vibrator:public")) api(project(":ui:core")) implementation(project(":ui:theme")) From 4c35a50b4e67ff72644950047080c6d7b78071e7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 15:13:16 -0400 Subject: [PATCH 04/39] feat(messenger): LinkCard.Web and web host rules --- .../features/messenger/build.gradle.kts | 2 + .../internal/link/LinkCardResolver.kt | 4 ++ .../internal/link/MessageLinkPrefetcher.kt | 3 +- .../app/messenger/internal/link/WebLinks.kt | 34 ++++++++++ .../messenger/internal/link/WebLinksTest.kt | 67 +++++++++++++++++++ .../flipcash/shared/chat/models/LinkCard.kt | 26 +++++++ .../flipcash/shared/chat/ui/LinkCardView.kt | 2 +- .../flipcash/shared/chat/ui/MessageBubble.kt | 2 + 8 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 756ccc8980..e7ba6136bd 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -42,6 +42,8 @@ dependencies { implementation(libs.bundles.haze) // Stored link previews (PersistedLinkCardMemory) are JSON. implementation(libs.kotlinx.serialization.json) + // HttpUrl parses and normalises outside links (WebLinks). + implementation(libs.okhttp) testImplementation(libs.bundles.unit.testing) testImplementation(libs.mockito.kotlin) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt index 0c7c61e948..10304038c7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt @@ -117,6 +117,8 @@ internal class LinkCardResolver( is LinkCard.TokenInfo -> tokenAnswers[card.mint]?.let { card.copy(state = it) } is LinkCard.GroupInvite -> groupAnswers[card.chatId]?.let { card.copy(state = it) } is LinkCard.User -> userAnswers[card.identity]?.let { card.copy(state = it) } + // Task 6 + is LinkCard.Web -> null } override suspend fun resolve(card: LinkCard): LinkCard = when (card) { @@ -124,6 +126,8 @@ internal class LinkCardResolver( is LinkCard.TokenInfo -> card.copy(state = tokenState(card.mint)) is LinkCard.GroupInvite -> card.copy(state = groupState(card.chatId)) is LinkCard.User -> card.copy(state = userState(card.identity)) + // Task 6 + is LinkCard.Web -> card } /** diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt index 325f55a17b..db46861208 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt @@ -64,7 +64,8 @@ internal class MessageLinkPrefetcher( is LinkCard.User -> card.identity .takeUnless { it in memory.users } ?.let { identity -> start(identity) { user(identity).onSuccess { memory.putUser(identity, it) } } } - is LinkCard.Cash, is LinkCard.TokenInfo -> null + // Web lookups arrive in Task 7. + is LinkCard.Cash, is LinkCard.TokenInfo, is LinkCard.Web -> null } private fun start(key: Any, block: suspend () -> Unit): Deferred = diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt new file mode 100644 index 0000000000..08502cab55 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.messenger.internal.link + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +/** Rules shared by every step of a web link preview. Mirrors `link_metadata.json`. */ +internal object WebLinks { + const val MAX_BODY_BYTES = 512 * 1024 + const val MAX_IMAGE_BYTES = 2 * 1024 * 1024 + const val MAX_REDIRECTS = 3 + const val MAX_CONCURRENT = 4 + val TIMEOUT = 5.seconds + val RESOLVED_TTL = 168.hours + val EMPTY_TTL = 24.hours + const val USER_AGENT = "Mozilla/5.0 (compatible; FlipcashLinkPreview/1.0)" + + private val blockedSuffixes = listOf(".localhost", ".local", ".internal") + private val ipv4 = Regex("""^\d{1,3}(\.\d{1,3}){3}$""") + + /** A host string a preview may fetch. IP literals never are; neither is a single label. */ + fun isEligibleHost(host: String): Boolean { + val h = host.lowercase() + if (h.startsWith("[") || ':' in h || ipv4.matches(h)) return false + if (h == "localhost" || blockedSuffixes.any { h.endsWith(it) }) return false + return '.' in h + } + + /** Lowercased scheme and host, no fragment, no :443. Path and query kept as written. */ + fun cacheKey(url: String): String? { + val u = url.toHttpUrlOrNull() ?: return null + return u.newBuilder().fragment(null).build().toString() + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt new file mode 100644 index 0000000000..9144912325 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt @@ -0,0 +1,67 @@ +package com.flipcash.app.messenger.internal.link + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * Host, cache key and limit tables of `link_metadata.json`. Synced copy -- a failure is fixed in the + * canonical fixture and re-synced to both platforms, never edited here. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class WebLinksTest { + + private fun fixture(): JSONObject = JSONObject( + javaClass.classLoader!! + .getResourceAsStream("link_metadata.json")!! + .bufferedReader().use { it.readText() } + ) + + @Test + fun `hosts match the cross-platform fixture`() { + val rows = fixture().getJSONArray("hosts") + assertEquals(true, rows.length() > 0) + for (i in 0 until rows.length()) { + val row = rows.getJSONObject(i) + val host = row.getString("host") + assertEquals(row.getBoolean("eligible"), WebLinks.isEligibleHost(host), "host `$host`") + } + } + + @Test + fun `cache keys match the cross-platform fixture`() { + val rows = fixture().getJSONArray("cacheKeys") + assertEquals(true, rows.length() > 0) + for (i in 0 until rows.length()) { + val row = rows.getJSONObject(i) + val url = row.getString("url") + assertEquals(row.getString("key"), WebLinks.cacheKey(url), "url `$url`") + } + } + + @Test + fun `limits match the cross-platform fixture`() { + val limits = fixture().getJSONObject("limits") + assertEquals(limits.getInt("maxBodyBytes"), WebLinks.MAX_BODY_BYTES) + assertEquals(limits.getInt("maxImageBytes"), WebLinks.MAX_IMAGE_BYTES) + assertEquals(limits.getInt("maxRedirects"), WebLinks.MAX_REDIRECTS) + assertEquals(limits.getInt("maxConcurrent"), WebLinks.MAX_CONCURRENT) + assertEquals(limits.getLong("timeoutSeconds"), WebLinks.TIMEOUT.inWholeSeconds) + assertEquals(limits.getLong("resolvedTtlHours"), WebLinks.RESOLVED_TTL.inWholeHours) + assertEquals(limits.getLong("emptyTtlHours"), WebLinks.EMPTY_TTL.inWholeHours) + } + + @Test + fun `a bracketed ipv6 url is refused through HttpUrl host`() { + // HttpUrl drops the brackets; the colon check is what keeps the literal out. + assertFalse(WebLinks.isEligibleHost("https://[::1]/".toHttpUrl().host)) + assertFalse(WebLinks.isEligibleHost("https://[2606:4700::1111]/".toHttpUrl().host)) + assertFalse(WebLinks.isEligibleHost("https://[::ffff:10.0.0.1]/".toHttpUrl().host)) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt index fa8ea19577..ee4ac6bcbe 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt @@ -232,4 +232,30 @@ sealed interface LinkCard { } } } + + /** + * An outside https page. Unlike the Flipcash cards it does not replace its link: the text keeps the + * link, and this draws under it. Only [State.Resolved] draws anything. + */ + data class Web( + override val url: String, + override val start: Int, + override val end: Int, + val state: State = State.Loading, + ) : LinkCard { + sealed interface State { + data object Loading : State + + /** The host answered with nothing to show. Cached; draws nothing. */ + data object None : State + + /** [host] comes from the URL after redirects, never from the page. */ + data class Resolved( + val title: String, + val description: String?, + val imageUrl: String?, + val host: String, + ) : State + } + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 61024c6247..85f1f11cba 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -172,7 +172,7 @@ internal fun LinkCardView( is LinkCard.Cash -> CashLinkCard(card = live, height = height, shape = shape) is LinkCard.TokenInfo -> TokenLinkCard(card = live, height = height, shape = shape) // Drawn above; unreachable here. - is LinkCard.GroupInvite, is LinkCard.User -> Unit + is LinkCard.GroupInvite, is LinkCard.User, is LinkCard.Web -> Unit } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 901f7ca4ab..ae6f424896 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -758,6 +758,8 @@ private fun rememberLinkCardClick(): (LinkCard) -> Unit { ) // Only a resolved card takes a tap. Which screen it opens -- the person's DM, your own // tip card, or nothing for the person already on the other end -- is the chat's call. + // A web card's own tap goes through LocalUriHandler (Task 8); nothing to do here. + is LinkCard.Web -> Unit is LinkCard.User -> (card.state as? LinkCard.User.State.Resolved)?.let { actionHandler(ChatAction.OpenUser(userId = it.userId, profile = it.profile, isOwn = it.isOwn)) } From 731440d242f188d21ce2472ac694edb8f518514d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 15:13:16 -0400 Subject: [PATCH 05/39] feat(messenger): classify outside https links as web cards --- .../messenger/internal/link/LinkCardClassifier.kt | 15 ++++++++++++++- .../internal/link/LinkCardClassifierTest.kt | 7 +++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt index 04c26113a9..86a5deb435 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt @@ -9,6 +9,7 @@ import com.flipcash.app.router.Router import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.ui.DetectedUrl import dev.theolm.rinku.DeepLink +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import javax.inject.Inject /** @@ -48,7 +49,19 @@ internal class LinkCardClassifier @Inject constructor( * from: the bubble draws the card in place of that text, and only the detection pass knows * where it sat. */ - fun firstCard(links: List): LinkCard? = links.firstNotNullOfOrNull { classify(it) } + fun firstCard(links: List): LinkCard? = + links.firstNotNullOfOrNull { classify(it) } ?: links.firstNotNullOfOrNull { web(it) } + + /** + * An outside https link. A card host never falls through to here, whatever its path, and that + * includes a jump wrapper around an outside target. + */ + private fun web(link: DetectedUrl): LinkCard.Web? { + val url = link.url.toHttpUrlOrNull() ?: return null + if (url.scheme != "https") return null + if (url.host in CARD_HOSTS || !WebLinks.isEligibleHost(url.host)) return null + return LinkCard.Web(url = link.url, start = link.start, end = link.end) + } private fun classify(link: DetectedUrl): LinkCard? { val target = unwrapJumpTarget(link.url) ?: link.url diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt index cc7f5d8b1b..fab5594f2c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt @@ -235,7 +235,10 @@ class LinkCardClassifierTest { } } - /** The router reads any single segment as a handle, so only the host gate stops these. */ + /** + * The router reads any single segment as a handle, so only the host gate stops these from + * becoming person cards; they fall through to the outside-link card. + */ @Test fun `a person shaped link on another host stays a link`() { listOf( @@ -243,7 +246,7 @@ class LinkCardClassifierTest { "https://t.me/satoshi", "https://example.com/2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b", "https://example.com/tip/2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b", - ).forEach { assertNull(cardFor(it), it) } + ).forEach { assertTrue(cardFor(it) is LinkCard.Web, it) } } @Test From 08e39d2fe98072ab4a1da4753b037884b8065683 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 15:29:04 -0400 Subject: [PATCH 06/39] feat(messenger): web page head scanner held to link_metadata.json --- .../messenger/internal/link/WebPageParser.kt | 159 ++++++++++++++++++ .../internal/link/WebPageParserTest.kt | 54 ++++++ 2 files changed, 213 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPageParser.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebPageParserTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPageParser.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPageParser.kt new file mode 100644 index 0000000000..72ecfaf784 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPageParser.kt @@ -0,0 +1,159 @@ +package com.flipcash.app.messenger.internal.link + +import com.flipcash.shared.chat.models.LinkCard +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * A single-pass scanner for a page's head. Hand-written rather than jsoup because jsoup decodes + * every HTML entity and the `link_metadata.json` rule keeps unknown named entities as written. + * Steps follow the fixture's rules and the iOS scanner, in the same order. + */ +internal object WebPageParser { + + /** Null when the page has no title. */ + fun parse(body: ByteArray, finalUrl: String): LinkCard.Web.State.Resolved? { + val html = String(body, Charsets.UTF_8) + val head = html.substring(0, headEnd(html)) + + val metas = HashMap() + var titleText: String? = null + var i = 0 + while (true) { + val lt = head.indexOf('<', i) + if (lt < 0) break + i = when { + head.startsWith(" { + val end = readMeta(head, lt + 5, metas) + end + } + head.startsWith(" { + val open = head.indexOf('>', lt + 6) + if (open < 0) break + val close = head.indexOf(" lt + 1 + } + } + + val title = metas["og:title"] ?: titleText ?: return null + val description = metas["og:description"] ?: metas["description"] + val base = finalUrl.toHttpUrlOrNull() + val image = metas["og:image"] + ?.let { base?.resolve(it) } + ?.takeIf { it.scheme == "https" && WebLinks.isEligibleHost(it.host) } + ?.toString() + val host = (base?.host ?: return null).lowercase().removePrefix("www.") + return LinkCard.Web.State.Resolved(title, description, image, host) + } + + private fun headEnd(html: String): Int { + val a = html.indexOf(" html.length + a < 0 -> b + b < 0 -> a + else -> minOf(a, b) + } + } + + /** True when the tag name ends at [index], so `= s.length || s[index].isWhitespace() || s[index] == '/' || s[index] == '>' + + /** Reads attributes from [from] to the closing `>`, records the first non-empty value, returns the next index. */ + private fun readMeta(s: String, from: Int, out: MutableMap): Int { + var i = from + var property: String? = null + var name: String? = null + var content: String? = null + while (i < s.length && s[i] != '>') { + if (s[i].isWhitespace() || s[i] == '/') { i++; continue } + val keyStart = i + while (i < s.length && !s[i].isWhitespace() && s[i] !in "=/>") i++ + val key = s.substring(keyStart, i).lowercase() + while (i < s.length && s[i].isWhitespace()) i++ + var value = "" + if (i < s.length && s[i] == '=') { + i++ + while (i < s.length && s[i].isWhitespace()) i++ + if (i < s.length && (s[i] == '"' || s[i] == '\'')) { + val q = s[i] + val close = s.indexOf(q, i + 1) + val end = if (close < 0) s.length else close + value = s.substring(i + 1, end) + i = if (close < 0) s.length else close + 1 + } else { + val vs = i + while (i < s.length && !s[i].isWhitespace() && s[i] != '>') i++ + value = s.substring(vs, i) + } + } + when (key) { + "property" -> if (property == null) property = value + "name" -> if (name == null) name = value + "content" -> if (content == null) content = value + } + } + val key = (property ?: name)?.let { clean(it) }?.lowercase() + val text = content?.let { clean(decode(it)) } + if (key != null && text != null) out.putIfAbsent(key, text) + return minOf(i + 1, s.length) + } + + private fun decode(s: String): String { + if ('&' !in s) return s + val sb = StringBuilder(s.length) + var i = 0 + while (i < s.length) { + val c = s[i] + if (c == '&') { + val semi = s.indexOf(';', i + 1) + if (semi in (i + 2)..(i + 12)) { + val decoded = entity(s.substring(i + 1, semi)) + if (decoded != null) { + sb.append(decoded) + i = semi + 1 + continue + } + } + } + sb.append(c) + i++ + } + return sb.toString() + } + + private fun entity(name: String): String? = when (name) { + "amp" -> "&" + "lt" -> "<" + "gt" -> ">" + "quot" -> "\"" + "apos" -> "'" + "nbsp" -> " " + else -> if (name.startsWith("#")) { + val hex = name.startsWith("#x") || name.startsWith("#X") + val cp = name.substring(if (hex) 2 else 1).toIntOrNull(if (hex) 16 else 10) + cp?.takeIf { it in 1..0x10FFFF && it !in 0xD800..0xDFFF }?.let { String(Character.toChars(it)) } + } else null + } + + /** NBSP to space, runs collapsed, trimmed; empty becomes null. */ + private fun clean(s: String): String? { + val sb = StringBuilder(s.length) + var space = false + for (c in s) { + if (c.isWhitespace() || c == ' ') { + space = sb.isNotEmpty() + } else { + if (space) sb.append(' ') + space = false + sb.append(c) + } + } + return sb.toString().ifEmpty { null } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebPageParserTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebPageParserTest.kt new file mode 100644 index 0000000000..ba9e5ce57b --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebPageParserTest.kt @@ -0,0 +1,54 @@ +package com.flipcash.app.messenger.internal.link + +import com.flipcash.shared.chat.models.LinkCard +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** The `pages` vectors of `link_metadata.json`. A failure is fixed in the canonical fixture, never here. */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class WebPageParserTest { + + private fun fixture(): JSONObject = JSONObject( + javaClass.classLoader!! + .getResourceAsStream("link_metadata.json")!! + .bufferedReader().use { it.readText() } + ) + + private fun JSONObject.stringOrNull(key: String): String? = + if (isNull(key)) null else getString(key) + + @Test + fun `pages match the cross-platform fixture`() { + val rows = fixture().getJSONArray("pages") + assertTrue(rows.length() > 0) + val failures = mutableListOf() + for (i in 0 until rows.length()) { + val row = rows.getJSONObject(i) + val name = row.getString("name") + val expected = if (row.isNull("expect")) null else row.getJSONObject("expect").let { + LinkCard.Web.State.Resolved( + title = it.getString("title"), + description = it.stringOrNull("description"), + imageUrl = it.stringOrNull("imageUrl"), + host = it.getString("host"), + ) + } + val actual = runCatching { + WebPageParser.parse(row.getString("html").toByteArray(), row.getString("finalUrl")) + } + if (actual.isFailure) { + failures += "$name: threw ${actual.exceptionOrNull()}" + } else if (actual.getOrNull() != expected) { + failures += "$name: expected $expected but was ${actual.getOrNull()}" + } + } + if (failures.isNotEmpty()) fail("${failures.size} of ${rows.length()} vectors failed:\n" + failures.joinToString("\n")) + } +} From 559ac7fc81dfed3ccb9ed4cbbf3027870c75caa1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 15:49:48 -0400 Subject: [PATCH 07/39] fix(messenger): give a percent-escaped host no web card 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. --- .../app/messenger/internal/link/LinkCardClassifier.kt | 1 + .../flipcash/app/messenger/internal/link/WebLinks.kt | 11 +++++++++++ .../messenger/internal/link/LinkCardClassifierTest.kt | 6 ++++++ .../app/messenger/internal/link/WebLinksTest.kt | 8 ++++++++ 4 files changed, 26 insertions(+) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt index 86a5deb435..0720130962 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt @@ -57,6 +57,7 @@ internal class LinkCardClassifier @Inject constructor( * includes a jump wrapper around an outside target. */ private fun web(link: DetectedUrl): LinkCard.Web? { + if (WebLinks.hasEscapedHost(link.url)) return null val url = link.url.toHttpUrlOrNull() ?: return null if (url.scheme != "https") return null if (url.host in CARD_HOSTS || !WebLinks.isEligibleHost(url.host)) return null diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt index 08502cab55..2958db7b6b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt @@ -26,6 +26,17 @@ internal object WebLinks { return '.' in h } + /** + * Whether [url] writes its host with a percent escape, as in `https://ex%61mple.com/`. `HttpUrl` + * decodes the escape and would fetch the decoded host, while iOS keeps it, so both apps give + * such a link no card (parity decision D11). + */ + fun hasEscapedHost(url: String): Boolean { + val authority = url.substringAfter("://", missingDelimiterValue = "") + .takeWhile { it != '/' && it != '?' && it != '#' } + return '%' in authority.substringAfterLast('@') + } + /** Lowercased scheme and host, no fragment, no :443. Path and query kept as written. */ fun cacheKey(url: String): String? { val u = url.toHttpUrlOrNull() ?: return null diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt index fab5594f2c..d91ac88bba 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt @@ -249,6 +249,12 @@ class LinkCardClassifierTest { ).forEach { assertTrue(cardFor(it) is LinkCard.Web, it) } } + /** HttpUrl would decode the escape and fetch `example.com`; iOS refuses it (parity decision D11). */ + @Test + fun `a percent escaped host gets no web card`() { + assertNull(cardFor("https://ex%61mple.com/")) + } + @Test fun `a website page stays a link`() { listOf( diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt index 9144912325..436a8c5002 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt @@ -64,4 +64,12 @@ class WebLinksTest { assertFalse(WebLinks.isEligibleHost("https://[2606:4700::1111]/".toHttpUrl().host)) assertFalse(WebLinks.isEligibleHost("https://[::ffff:10.0.0.1]/".toHttpUrl().host)) } + + @Test + fun `a percent escape in the host is refused, elsewhere it is not`() { + assertEquals(true, WebLinks.hasEscapedHost("https://ex%61mple.com/")) + assertEquals(true, WebLinks.hasEscapedHost("https://user@ex%61mple.com:443/a")) + assertFalse(WebLinks.hasEscapedHost("https://example.com/a%20b?q=%41#%42")) + assertFalse(WebLinks.hasEscapedHost("https://us%40er@example.com/")) + } } From 8b09ebc8478c1aad708c0bf27c102924a76d98a2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 15:55:35 -0400 Subject: [PATCH 08/39] feat(messenger): web link lookup with public-only dns and fetch caps --- .../features/messenger/build.gradle.kts | 3 + .../messenger/internal/link/WebLinkLookup.kt | 135 +++++++++++ .../internal/link/PublicOnlyDnsTest.kt | 58 +++++ .../internal/link/WebLinkLookupTest.kt | 218 ++++++++++++++++++ gradle/libs.versions.toml | 2 + 5 files changed, 416 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index e7ba6136bd..241749dcc6 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -47,6 +47,9 @@ dependencies { testImplementation(libs.bundles.unit.testing) testImplementation(libs.mockito.kotlin) + // A TLS MockWebServer, to see headers as OkHttp really sends and receives them (WebLinkLookupTest). + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.okhttp.tls) testImplementation(libs.robolectric) testImplementation(libs.androidx.paging.testing) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt new file mode 100644 index 0000000000..6098f0d37a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -0,0 +1,135 @@ +package com.flipcash.app.messenger.internal.link + +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.shared.chat.models.LinkCard +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback +import okhttp3.CookieJar +import okhttp3.Dns +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.IOException +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.UnknownHostException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.time.toJavaDuration + +/** Drops private, loopback and link-local answers, so a public name cannot point the fetch inward. */ +internal class PublicOnlyDns(private val delegate: Dns = Dns.SYSTEM) : Dns { + override fun lookup(hostname: String): List = + delegate.lookup(hostname).filterNot { it.isPrivate() } + .ifEmpty { throw UnknownHostException("$hostname resolves only to private addresses") } +} + +/** The fixture's `addresses` rule. Java already turns an IPv4-mapped IPv6 literal into an [Inet4Address]. */ +internal fun InetAddress.isPrivate(): Boolean { + if (isLoopbackAddress || isLinkLocalAddress || isSiteLocalAddress || isAnyLocalAddress || isMulticastAddress) return true + val b = address.map { it.toInt() and 0xFF } + return when (this) { + is Inet4Address -> b[0] == 0 || (b[0] == 100 && (b[1] and 0xC0) == 64) || b.all { it == 255 } + is Inet6Address -> (b[0] and 0xFE) == 0xFC + else -> true + } +} + +/** + * No cookie jar, no authenticator and no cache, and it must not be built from an app client whose + * interceptors add credentials. Redirects are followed by hand so each hop is checked. + */ +internal fun webPreviewClient(dns: Dns = PublicOnlyDns()): OkHttpClient = OkHttpClient.Builder() + .dns(dns) + .cookieJar(CookieJar.NO_COOKIES) + .cache(null) + .followRedirects(false) + .followSslRedirects(false) + .connectTimeout(WebLinks.TIMEOUT.toJavaDuration()) + .readTimeout(WebLinks.TIMEOUT.toJavaDuration()) + .callTimeout((WebLinks.TIMEOUT * 2).toJavaDuration()) + .build() + +/** The client implementation of the spec's LinkMetadataSource. A server RPC replaces it. */ +internal class WebLinkLookup( + private val client: OkHttpClient, + private val enabled: suspend () -> Boolean, + private val dispatchers: DispatcherProvider, +) { + private val inFlight = Semaphore(WebLinks.MAX_CONCURRENT) + + suspend operator fun invoke(url: String): Result = inFlight.withPermit { fetch(url) } + + private suspend fun fetch(url: String): Result = withContext(dispatchers.IO) { + if (!enabled()) return@withContext Result.failure(IllegalStateException("web previews off")) + runCatching { + var current = url.toHttpUrl() + // The first request plus MAX_REDIRECTS redirects. A redirect answering the last one is None. + repeat(WebLinks.MAX_REDIRECTS + 1) { + if (current.scheme != "https" || !WebLinks.isEligibleHost(current.host)) { + return@runCatching LinkCard.Web.State.None + } + client.newCall(request(current)).await().use { response -> + when { + response.isRedirect -> { + val location = response.header("Location") + if (location == null || WebLinks.hasEscapedHost(location)) { + return@runCatching LinkCard.Web.State.None + } + current = current.resolve(location) ?: return@runCatching LinkCard.Web.State.None + } + response.code >= 500 -> throw IOException("HTTP ${response.code}") + !response.isSuccessful -> return@runCatching LinkCard.Web.State.None + !response.isHtml() -> return@runCatching LinkCard.Web.State.None + // The caps count bytes read, so a compressed body is not read at all. + response.isEncoded() -> return@runCatching LinkCard.Web.State.None + else -> return@runCatching WebPageParser.parse(response.capped(), current.toString()) + ?: LinkCard.Web.State.None + } + } + } + LinkCard.Web.State.None + }.onFailure { if (it is CancellationException) throw it } + } + + // Set on the request, so OkHttp's bridge sees Accept-Encoding and leaves the body encoded. + private fun request(url: HttpUrl) = Request.Builder().url(url) + .header("User-Agent", WebLinks.USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml") + .header("Accept-Encoding", "identity") + .build() + + private fun Response.isHtml() = body.contentType()?.let { + it.subtype == "html" || it.subtype == "xhtml+xml" + } ?: false + + private fun Response.isEncoded() = header("Content-Encoding") + ?.let { !it.trim().equals("identity", ignoreCase = true) } ?: false + + private fun Response.capped(): ByteArray { + val source = body.source() + source.request(WebLinks.MAX_BODY_BYTES.toLong()) + return source.buffer.readByteArray(minOf(source.buffer.size, WebLinks.MAX_BODY_BYTES.toLong())) + } +} + +private suspend fun Call.await(): Response = suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { cancel() } + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + cont.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response) { _, value, _ -> value.close() } + } + }) +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt new file mode 100644 index 0000000000..b26ce35364 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt @@ -0,0 +1,58 @@ +package com.flipcash.app.messenger.internal.link + +import okhttp3.Dns +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.net.InetAddress +import java.net.UnknownHostException +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** The `addresses` vectors of `link_metadata.json`. A failure is fixed in the canonical fixture, never here. */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PublicOnlyDnsTest { + + private fun fixture(): JSONObject = JSONObject( + javaClass.classLoader!! + .getResourceAsStream("link_metadata.json")!! + .bufferedReader().use { it.readText() } + ) + + @Test + fun `addresses match the cross-platform fixture`() { + val rows = fixture().getJSONArray("addresses") + assertTrue(rows.length() > 0) + val failures = mutableListOf() + for (i in 0 until rows.length()) { + val row = rows.getJSONObject(i) + val address = row.getString("address") + val actual = runCatching { InetAddress.getByName(address).isPrivate() } + when { + actual.isFailure -> failures += "$address (${row.getString("note")}): threw ${actual.exceptionOrNull()}" + actual.getOrNull() == row.getBoolean("public") -> + failures += "$address (${row.getString("note")}): expected public=${row.getBoolean("public")} but isPrivate=${actual.getOrNull()}" + } + } + assertTrue(failures.isEmpty(), failures.joinToString("\n")) + } + + private fun dnsOf(vararg literals: String) = Dns { literals.map { InetAddress.getByName(it) } } + + @Test + fun `private answers are filtered out`() { + val answers = PublicOnlyDns(dnsOf("10.0.0.1", "93.184.215.14", "127.0.0.1")).lookup("example.com") + assertEquals(listOf(InetAddress.getByName("93.184.215.14")), answers) + } + + @Test + fun `an answer with nothing left throws`() { + assertFailsWith { + PublicOnlyDns(dnsOf("10.0.0.1", "169.254.169.254")).lookup("example.com") + } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt new file mode 100644 index 0000000000..ddb8e26aa4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt @@ -0,0 +1,218 @@ +package com.flipcash.app.messenger.internal.link + +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.shared.chat.models.LinkCard +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import okhttp3.Dns +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.junit.Test +import java.io.IOException +import java.net.InetAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Fetch rules. Most cases script the answers through an application interceptor, so nothing touches + * the network. The Content-Encoding case runs against a real TLS [MockWebServer], because only a real + * call goes through OkHttp's bridge interceptor, which is what would strip the header. + */ +class WebLinkLookupTest { + + private val realIo = object : DispatcherProvider { + override val Default: CoroutineDispatcher = Dispatchers.Default + override val Main: CoroutineDispatcher = Dispatchers.Default + override val IO: CoroutineDispatcher = Dispatchers.IO + } + + private val html = """Hello""" + + private fun reply( + request: Request, + code: Int = 200, + type: String? = "text/html; charset=utf-8", + body: ByteArray = html.toByteArray(), + headers: Map = emptyMap(), + ): Response = Response.Builder() + .request(request).protocol(Protocol.HTTP_1_1).code(code).message("m") + .apply { headers.forEach { (k, v) -> header(k, v) } } + .body(body.toResponseBody(type?.toMediaType())) + .build() + + private fun redirect(request: Request, to: String) = reply(request, 302, null, ByteArray(0), mapOf("Location" to to)) + + private class Script(val handler: (Request) -> Response) { + val seen = CopyOnWriteArrayList() + val interceptor = Interceptor { chain -> chain.request().also { seen += it }.let(handler) } + } + + private fun lookup(script: Script, enabled: Boolean = true) = WebLinkLookup( + client = webPreviewClient().newBuilder().addInterceptor(script.interceptor).build(), + enabled = { enabled }, + dispatchers = realIo, + ) + + private fun fetch(script: Script, url: String = "https://example.com/a", enabled: Boolean = true) = + runBlocking { lookup(script, enabled)(url) } + + private fun Result.state(): LinkCard.Web.State = getOrThrow() + + @Test + fun `a 200 html page resolves with the host of the last url`() { + val script = Script { req -> + when (req.url.host) { + "example.com" -> redirect(req, "https://www.final.org/page") + else -> reply(req) + } + } + val state = fetch(script).state() + assertEquals(LinkCard.Web.State.Resolved("Hello", null, null, "final.org"), state) + } + + @Test + fun `three redirects are followed and a fourth gives none`() { + val hops = AtomicInteger() + val follows = Script { req -> + if (hops.getAndIncrement() < 3) redirect(req, "https://example.com/${hops.get()}") else reply(req) + } + assertTrue(fetch(follows).state() is LinkCard.Web.State.Resolved) + assertEquals(4, follows.seen.size) + + val never = Script { req -> redirect(req, "https://example.com/next") } + assertEquals(LinkCard.Web.State.None, fetch(never).state()) + assertEquals(4, never.seen.size) + } + + @Test + fun `an ineligible redirect target gives none and is not requested`() { + for (target in listOf( + "http://example.com/", + "https://10.0.0.1/", + "https://printer.local/", + "https://ex%61mple.com/", + )) { + val script = Script { req -> redirect(req, target) } + assertEquals(LinkCard.Web.State.None, fetch(script).state(), target) + assertEquals(1, script.seen.size, target) + } + } + + @Test + fun `non html and 404 give none, 503 and io errors fail`() { + assertEquals(LinkCard.Web.State.None, fetch(Script { reply(it, type = "application/json") }).state()) + assertEquals(LinkCard.Web.State.None, fetch(Script { reply(it, code = 404) }).state()) + assertTrue(fetch(Script { reply(it, code = 503) }).isFailure) + assertTrue(fetch(Script { throw IOException("down") }).isFailure) + } + + @Test + fun `a body is read only up to the cap`() { + val filler = " ".repeat(WebLinks.MAX_BODY_BYTES + 10) + val late = "$fillerLate".toByteArray() + assertEquals(LinkCard.Web.State.None, fetch(Script { reply(it, body = late) }).state()) + + val early = "Early$filler".toByteArray() + assertEquals("Early", (fetch(Script { reply(it, body = early) }).state() as LinkCard.Web.State.Resolved).title) + } + + @Test + fun `requests carry the agreed headers and no credentials`() { + val script = Script { reply(it) } + fetch(script) + val request = script.seen.single() + assertNull(request.header("Cookie")) + assertNull(request.header("Authorization")) + assertEquals(WebLinks.USER_AGENT, request.header("User-Agent")) + assertEquals("text/html,application/xhtml+xml", request.header("Accept")) + assertEquals("identity", request.header("Accept-Encoding")) + } + + @Test + fun `with the flag off the result is a failure and nothing is requested`() { + val script = Script { reply(it) } + assertTrue(fetch(script, enabled = false).isFailure) + assertTrue(script.seen.isEmpty()) + } + + @Test + fun `six calls at once never have more than four requests open`() { + val open = AtomicInteger() + val peak = AtomicInteger() + val release = CountDownLatch(1) + val script = Script { req -> + peak.accumulateAndGet(open.incrementAndGet()) { a, b -> maxOf(a, b) } + try { + release.await(10, TimeUnit.SECONDS) + reply(req) + } finally { + open.decrementAndGet() + } + } + val lookup = lookup(script) + runBlocking(Dispatchers.IO) { + val calls = (1..6).map { async { lookup("https://example.com/$it") } } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (open.get() < WebLinks.MAX_CONCURRENT && System.nanoTime() < deadline) Thread.sleep(10) + // Give a fifth request the chance to (wrongly) start. + Thread.sleep(200) + assertEquals(WebLinks.MAX_CONCURRENT, open.get()) + release.countDown() + calls.awaitAll().forEach { assertTrue(it.getOrThrow() is LinkCard.Web.State.Resolved) } + } + assertEquals(WebLinks.MAX_CONCURRENT, peak.get()) + } + + @Test + fun `an encoded page gives none and OkHttp left the header visible`() { + val host = "preview.example.com" + val held = HeldCertificate.Builder().addSubjectAlternativeName(host).build() + val serverCerts = HandshakeCertificates.Builder().heldCertificate(held).build() + val clientCerts = HandshakeCertificates.Builder().addTrustedCertificate(held.certificate).build() + val server = MockWebServer() + server.useHttps(serverCerts.sslSocketFactory()) + server.enqueue( + MockResponse.Builder().code(200) + .addHeader("Content-Type", "text/html") + .addHeader("Content-Encoding", "gzip") + .body(html) + .build() + ) + server.start() + try { + var sawEncoding: String? = "unset" + val client = webPreviewClient(Dns { listOf(InetAddress.getByName("127.0.0.1")) }) + .newBuilder() + .sslSocketFactory(clientCerts.sslSocketFactory(), clientCerts.trustManager) + .addInterceptor { chain -> + chain.proceed(chain.request()).also { sawEncoding = it.header("Content-Encoding") } + } + .build() + val result = runBlocking { + WebLinkLookup(client, { true }, realIo)("https://$host:${server.port}/") + } + assertEquals("gzip", sawEncoding, "OkHttp must not strip Content-Encoding") + assertEquals("identity", server.takeRequest().headers["Accept-Encoding"]) + assertEquals(LinkCard.Web.State.None, result.getOrThrow()) + } finally { + server.close() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4b5916bc70..da6ac2f820 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -190,6 +190,8 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa # OkHttp okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3", version.ref = "okhttp" } +okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" } okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } # Compose From abe0e2ab7068b5e3ae5d5c13f1bbebfc8523656e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 16:24:26 -0400 Subject: [PATCH 09/39] fix(messenger): keep link preview fetches off the system proxy --- .../messenger/internal/link/WebLinkLookup.kt | 5 +- .../internal/link/WebLinkLookupTest.kt | 73 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt index 6098f0d37a..9c61207b6c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -20,6 +20,7 @@ import java.io.IOException import java.net.Inet4Address import java.net.Inet6Address import java.net.InetAddress +import java.net.Proxy import java.net.UnknownHostException import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -45,10 +46,12 @@ internal fun InetAddress.isPrivate(): Boolean { /** * No cookie jar, no authenticator and no cache, and it must not be built from an app client whose - * interceptors add credentials. Redirects are followed by hand so each hop is checked. + * interceptors add credentials. Redirects are followed by hand so each hop is checked. It never + * uses a system proxy, which would resolve the name itself and so bypass [PublicOnlyDns]. */ internal fun webPreviewClient(dns: Dns = PublicOnlyDns()): OkHttpClient = OkHttpClient.Builder() .dns(dns) + .proxy(Proxy.NO_PROXY) .cookieJar(CookieJar.NO_COOKIES) .cache(null) .followRedirects(false) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt index ddb8e26aa4..6b196dbf5d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking +import okhttp3.CookieJar import okhttp3.Dns import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -21,6 +22,11 @@ import okhttp3.tls.HeldCertificate import org.junit.Test import java.io.IOException import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.SocketAddress +import java.net.URI import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -215,4 +221,71 @@ class WebLinkLookupTest { server.close() } } + + @Test + fun `the default client pins its transport settings`() { + val client = webPreviewClient() + assertEquals(false, client.followRedirects) + assertEquals(false, client.followSslRedirects) + assertTrue(client.dns is PublicOnlyDns) + assertEquals(CookieJar.NO_COOKIES, client.cookieJar) + assertNull(client.cache) + assertEquals(Proxy.NO_PROXY, client.proxy) + assertEquals(5_000, client.connectTimeoutMillis) + assertEquals(5_000, client.readTimeoutMillis) + assertEquals(10_000, client.callTimeoutMillis) + } + + /** A system proxy would take the CONNECT and resolve the name itself, so [PublicOnlyDns] would never run. */ + @Test + fun `a system proxy is never asked and the dns still runs`() { + val proxy = MockWebServer() + proxy.start() + try { + val asked = CopyOnWriteArrayList() + val resolved = CopyOnWriteArrayList() + val selector = object : ProxySelector() { + override fun select(uri: URI?): List { + asked += uri.toString() + return listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", proxy.port))) + } + + override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) = Unit + } + val client = webPreviewClient(Dns { host -> resolved += host; throw java.net.UnknownHostException(host) }) + .newBuilder().proxySelector(selector).build() + val result = runBlocking { WebLinkLookup(client, { true }, realIo)("https://example.com/") } + assertTrue(result.isFailure) + assertEquals(0, proxy.requestCount) + assertTrue(asked.isEmpty(), "proxy selector was consulted: $asked") + assertEquals(listOf("example.com"), resolved.toList()) + } finally { + proxy.close() + } + } + + /** The lookup's loop follows redirects and checks each hop; the client must not do it first. */ + @Test + fun `the client does not follow a redirect by itself`() { + val host = "preview.example.com" + val held = HeldCertificate.Builder().addSubjectAlternativeName(host).build() + val serverCerts = HandshakeCertificates.Builder().heldCertificate(held).build() + val clientCerts = HandshakeCertificates.Builder().addTrustedCertificate(held.certificate).build() + val server = MockWebServer() + server.useHttps(serverCerts.sslSocketFactory()) + server.enqueue(MockResponse.Builder().code(302).addHeader("Location", "http://$host/next").build()) + server.enqueue(MockResponse.Builder().code(200).addHeader("Content-Type", "text/html").body(html).build()) + server.start() + try { + val client = webPreviewClient(Dns { listOf(InetAddress.getByName("127.0.0.1")) }) + .newBuilder() + .sslSocketFactory(clientCerts.sslSocketFactory(), clientCerts.trustManager) + .build() + val result = runBlocking { WebLinkLookup(client, { true }, realIo)("https://$host:${server.port}/") } + assertEquals(LinkCard.Web.State.None, result.getOrThrow()) + assertEquals(1, server.requestCount) + } finally { + server.close() + } + } } From fd77e1cb330eeea00fb0f03844cccae2c64abf2d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 16:27:36 -0400 Subject: [PATCH 10/39] feat(messenger): give a non-443 port no web card --- .../internal/link/LinkCardClassifier.kt | 3 ++- .../messenger/internal/link/WebLinkLookup.kt | 3 ++- .../internal/link/LinkCardClassifierTest.kt | 9 +++++++++ .../internal/link/WebLinkLookupTest.kt | 19 +++++++++++++++++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt index 0720130962..679386767f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt @@ -59,7 +59,8 @@ internal class LinkCardClassifier @Inject constructor( private fun web(link: DetectedUrl): LinkCard.Web? { if (WebLinks.hasEscapedHost(link.url)) return null val url = link.url.toHttpUrlOrNull() ?: return null - if (url.scheme != "https") return null + // An explicit port other than 443 gets no card (parity decision D12). + if (url.scheme != "https" || url.port != 443) return null if (url.host in CARD_HOSTS || !WebLinks.isEligibleHost(url.host)) return null return LinkCard.Web(url = link.url, start = link.start, end = link.end) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt index 9c61207b6c..b216eef7ff 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -77,7 +77,8 @@ internal class WebLinkLookup( var current = url.toHttpUrl() // The first request plus MAX_REDIRECTS redirects. A redirect answering the last one is None. repeat(WebLinks.MAX_REDIRECTS + 1) { - if (current.scheme != "https" || !WebLinks.isEligibleHost(current.host)) { + // Port 443 only, on every hop (parity decision D12). + if (current.scheme != "https" || current.port != 443 || !WebLinks.isEligibleHost(current.host)) { return@runCatching LinkCard.Web.State.None } client.newCall(request(current)).await().use { response -> diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt index d91ac88bba..7f214e4d7a 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt @@ -255,6 +255,15 @@ class LinkCardClassifierTest { assertNull(cardFor("https://ex%61mple.com/")) } + /** iOS gives an explicit port other than 443 no card (parity decision D12). */ + @Test + fun `a port other than 443 gets no web card`() { + assertNull(cardFor("https://example.com:6379/")) + assertNull(cardFor("https://example.com:8443/a")) + assertTrue(cardFor("https://example.com:443/") is LinkCard.Web) + assertTrue(cardFor("https://example.com/") is LinkCard.Web) + } + @Test fun `a website page stays a link`() { listOf( diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt index 6b196dbf5d..f29bc446d0 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt @@ -79,6 +79,11 @@ class WebLinkLookupTest { private fun fetch(script: Script, url: String = "https://example.com/a", enabled: Boolean = true) = runBlocking { lookup(script, enabled)(url) } + /** The lookup only requests port 443, so a test server is reached by moving the call after the checks. */ + private fun portTo(port: Int) = Interceptor { chain -> + chain.proceed(chain.request().newBuilder().url(chain.request().url.newBuilder().port(port).build()).build()) + } + private fun Result.state(): LinkCard.Web.State = getOrThrow() @Test @@ -121,6 +126,14 @@ class WebLinkLookupTest { } } + /** A hop to a port other than 443 gives none and is not requested (parity decision D12). */ + @Test + fun `a redirect to another port gives none and is not requested`() { + val script = Script { req -> redirect(req, "https://example.com:8443/") } + assertEquals(LinkCard.Web.State.None, fetch(script).state()) + assertEquals(1, script.seen.size) + } + @Test fun `non html and 404 give none, 503 and io errors fail`() { assertEquals(LinkCard.Web.State.None, fetch(Script { reply(it, type = "application/json") }).state()) @@ -207,12 +220,13 @@ class WebLinkLookupTest { val client = webPreviewClient(Dns { listOf(InetAddress.getByName("127.0.0.1")) }) .newBuilder() .sslSocketFactory(clientCerts.sslSocketFactory(), clientCerts.trustManager) + .addInterceptor(portTo(server.port)) .addInterceptor { chain -> chain.proceed(chain.request()).also { sawEncoding = it.header("Content-Encoding") } } .build() val result = runBlocking { - WebLinkLookup(client, { true }, realIo)("https://$host:${server.port}/") + WebLinkLookup(client, { true }, realIo)("https://$host/") } assertEquals("gzip", sawEncoding, "OkHttp must not strip Content-Encoding") assertEquals("identity", server.takeRequest().headers["Accept-Encoding"]) @@ -280,8 +294,9 @@ class WebLinkLookupTest { val client = webPreviewClient(Dns { listOf(InetAddress.getByName("127.0.0.1")) }) .newBuilder() .sslSocketFactory(clientCerts.sslSocketFactory(), clientCerts.trustManager) + .addInterceptor(portTo(server.port)) .build() - val result = runBlocking { WebLinkLookup(client, { true }, realIo)("https://$host:${server.port}/") } + val result = runBlocking { WebLinkLookup(client, { true }, realIo)("https://$host/") } assertEquals(LinkCard.Web.State.None, result.getOrThrow()) assertEquals(1, server.requestCount) } finally { From 3a783fb30233aa9eb414f3d9dee5b5f31fb123f1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 16:29:29 -0400 Subject: [PATCH 11/39] feat(messenger): refuse dotted, numeric and NAT64 private hosts --- .../app/messenger/internal/link/WebLinkLookup.kt | 14 +++++++++++++- .../app/messenger/internal/link/WebLinks.kt | 8 +++++++- .../internal/link/LinkCardClassifierTest.kt | 8 ++++++++ .../messenger/internal/link/PublicOnlyDnsTest.kt | 11 +++++++++++ .../messenger/internal/link/WebLinkLookupTest.kt | 1 + .../app/messenger/internal/link/WebLinksTest.kt | 11 +++++++++++ 6 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt index b216eef7ff..3921bd41be 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -33,8 +33,12 @@ internal class PublicOnlyDns(private val delegate: Dns = Dns.SYSTEM) : Dns { .ifEmpty { throw UnknownHostException("$hostname resolves only to private addresses") } } -/** The fixture's `addresses` rule. Java already turns an IPv4-mapped IPv6 literal into an [Inet4Address]. */ +/** + * The fixture's `addresses` rule. Java already turns an IPv4-mapped IPv6 literal into an [Inet4Address]. + * A NAT64 address (`64:ff9b::/96`) is judged by the IPv4 address in its last 32 bits (parity decision D14). + */ internal fun InetAddress.isPrivate(): Boolean { + nat64Embedded()?.let { return it.isPrivate() } if (isLoopbackAddress || isLinkLocalAddress || isSiteLocalAddress || isAnyLocalAddress || isMulticastAddress) return true val b = address.map { it.toInt() and 0xFF } return when (this) { @@ -44,6 +48,14 @@ internal fun InetAddress.isPrivate(): Boolean { } } +private fun InetAddress.nat64Embedded(): InetAddress? { + if (this !is Inet6Address) return null + val b = address + val prefix = intArrayOf(0x00, 0x64, 0xFF, 0x9B) + val inPrefix = prefix.indices.all { (b[it].toInt() and 0xFF) == prefix[it] } && (4 until 12).all { b[it].toInt() == 0 } + return if (inPrefix) InetAddress.getByAddress(b.copyOfRange(12, 16)) else null +} + /** * No cookie jar, no authenticator and no cache, and it must not be built from an app client whose * interceptors add credentials. Redirects are followed by hand so each hop is checked. It never diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt index 2958db7b6b..abb6f24bd3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt @@ -17,11 +17,17 @@ internal object WebLinks { private val blockedSuffixes = listOf(".localhost", ".local", ".internal") private val ipv4 = Regex("""^\d{1,3}(\.\d{1,3}){3}$""") + private val numericLabel = Regex("""^(0x[0-9a-f]*|\d+)$""", RegexOption.IGNORE_CASE) - /** A host string a preview may fetch. IP literals never are; neither is a single label. */ + /** + * A host string a preview may fetch. IP literals never are; neither is a single label. A trailing + * dot and a host of only numeric or hex labels (`127.1`, `0x7f.0.0.1`) are refused too, because + * they are how a literal or `localhost` hides from the rules above (parity decision D13). + */ fun isEligibleHost(host: String): Boolean { val h = host.lowercase() if (h.startsWith("[") || ':' in h || ipv4.matches(h)) return false + if (h.endsWith(".") || h.split('.').all { numericLabel.matches(it) }) return false if (h == "localhost" || blockedSuffixes.any { h.endsWith(it) }) return false return '.' in h } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt index 7f214e4d7a..50847a941d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt @@ -264,6 +264,14 @@ class LinkCardClassifierTest { assertTrue(cardFor("https://example.com/") is LinkCard.Web) } + /** `HttpUrl` keeps the trailing dot, so the host rule has to refuse it (parity decision D13). */ + @Test + fun `a trailing dot host gets no web card`() { + assertNull(cardFor("https://localhost./")) + assertNull(cardFor("https://a.local./")) + assertNull(cardFor("https://example.com./")) + } + @Test fun `a website page stays a link`() { listOf( diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt index b26ce35364..e763f2c133 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt @@ -10,6 +10,7 @@ import java.net.InetAddress import java.net.UnknownHostException import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue /** The `addresses` vectors of `link_metadata.json`. A failure is fixed in the canonical fixture, never here. */ @@ -55,4 +56,14 @@ class PublicOnlyDnsTest { PublicOnlyDns(dnsOf("10.0.0.1", "169.254.169.254")).lookup("example.com") } } + + /** `64:ff9b::/96` carries an IPv4 address in its last 32 bits; it is judged by that address (parity decision D14). */ + @Test + fun `a nat64 address is judged by its embedded ipv4`() { + assertTrue(InetAddress.getByName("64:ff9b::a00:1").isPrivate()) + assertTrue(InetAddress.getByName("64:ff9b::7f00:1").isPrivate()) + assertTrue(InetAddress.getByName("64:ff9b::a9fe:a9fe").isPrivate()) + assertFalse(InetAddress.getByName("64:ff9b::808:808").isPrivate()) + assertFalse(InetAddress.getByName("2001:db8::a00:1").isPrivate()) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt index f29bc446d0..0a14b50129 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt @@ -118,6 +118,7 @@ class WebLinkLookupTest { "http://example.com/", "https://10.0.0.1/", "https://printer.local/", + "https://a.local./", "https://ex%61mple.com/", )) { val script = Script { req -> redirect(req, target) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt index 436a8c5002..3a5a3d81bb 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt @@ -72,4 +72,15 @@ class WebLinksTest { assertFalse(WebLinks.hasEscapedHost("https://example.com/a%20b?q=%41#%42")) assertFalse(WebLinks.hasEscapedHost("https://us%40er@example.com/")) } + + /** A trailing dot or an all-numeric host is how an IP literal or `localhost` hides (parity decision D13). */ + @Test + fun `a trailing dot or a numeric host is refused, a digit in a name is not`() { + for (host in listOf("localhost.", "a.local.", "example.com.", "1.1.1.1.", "127.1", "0x7f.0.0.1", "0177.0.0.1", "0X7F.1", "2130706433", "0x7f000001")) { + assertFalse(WebLinks.isEligibleHost(host), host) + } + for (host in listOf("1password.com", "123.example.com", "example.com", "0x.example.com", "a1.b2")) { + assertEquals(true, WebLinks.isEligibleHost(host), host) + } + } } From 6a3b1dffdd0b2b794caca3381c5cfbb22f3adc0d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 16:31:36 -0400 Subject: [PATCH 12/39] feat(messenger): refuse special-use address ranges --- .../messenger/internal/link/WebLinkLookup.kt | 18 ++++++++----- .../app/messenger/internal/link/WebLinks.kt | 4 +-- .../internal/link/PublicOnlyDnsTest.kt | 26 +++++++++++++++++++ .../messenger/internal/link/WebLinksTest.kt | 4 +-- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt index 3921bd41be..a1bcf07562 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -35,15 +35,19 @@ internal class PublicOnlyDns(private val delegate: Dns = Dns.SYSTEM) : Dns { /** * The fixture's `addresses` rule. Java already turns an IPv4-mapped IPv6 literal into an [Inet4Address]. - * A NAT64 address (`64:ff9b::/96`) is judged by the IPv4 address in its last 32 bits (parity decision D14). + * A NAT64 address (`64:ff9b::/96`) is judged by the IPv4 address in its last 32 bits, a mapped one + * the same way. 6to4 (`2002::/16`) and IPv4-compatible (`::/96`) addresses are always private, as + * are the IPv4 special-use blocks 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15 and 240.0.0.0/4 + * (parity decision D14). */ internal fun InetAddress.isPrivate(): Boolean { nat64Embedded()?.let { return it.isPrivate() } if (isLoopbackAddress || isLinkLocalAddress || isSiteLocalAddress || isAnyLocalAddress || isMulticastAddress) return true val b = address.map { it.toInt() and 0xFF } return when (this) { - is Inet4Address -> b[0] == 0 || (b[0] == 100 && (b[1] and 0xC0) == 64) || b.all { it == 255 } - is Inet6Address -> (b[0] and 0xFE) == 0xFC + is Inet4Address -> b[0] == 0 || (b[0] == 100 && (b[1] and 0xC0) == 64) || b[0] >= 240 || + (b[0] == 192 && b[1] == 0 && (b[2] == 0 || b[2] == 2)) || (b[0] == 198 && (b[1] and 0xFE) == 18) + is Inet6Address -> (b[0] and 0xFE) == 0xFC || (b[0] == 0x20 && b[1] == 0x02) || (0 until 12).all { b[it] == 0 } else -> true } } @@ -51,9 +55,11 @@ internal fun InetAddress.isPrivate(): Boolean { private fun InetAddress.nat64Embedded(): InetAddress? { if (this !is Inet6Address) return null val b = address - val prefix = intArrayOf(0x00, 0x64, 0xFF, 0x9B) - val inPrefix = prefix.indices.all { (b[it].toInt() and 0xFF) == prefix[it] } && (4 until 12).all { b[it].toInt() == 0 } - return if (inPrefix) InetAddress.getByAddress(b.copyOfRange(12, 16)) else null + val nat64 = intArrayOf(0x00, 0x64, 0xFF, 0x9B).let { p -> + p.indices.all { (b[it].toInt() and 0xFF) == p[it] } && (4 until 12).all { b[it].toInt() == 0 } + } + val mapped = (0 until 10).all { b[it].toInt() == 0 } && b[10].toInt() == -1 && b[11].toInt() == -1 + return if (nat64 || mapped) InetAddress.getByAddress(b.copyOfRange(12, 16)) else null } /** diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt index abb6f24bd3..580834bd36 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt @@ -17,11 +17,11 @@ internal object WebLinks { private val blockedSuffixes = listOf(".localhost", ".local", ".internal") private val ipv4 = Regex("""^\d{1,3}(\.\d{1,3}){3}$""") - private val numericLabel = Regex("""^(0x[0-9a-f]*|\d+)$""", RegexOption.IGNORE_CASE) + private val numericLabel = Regex("""^(0x[0-9a-f]+|\d+)$""", RegexOption.IGNORE_CASE) /** * A host string a preview may fetch. IP literals never are; neither is a single label. A trailing - * dot and a host of only numeric or hex labels (`127.1`, `0x7f.0.0.1`) are refused too, because + * dot and a host of only numeric or hex labels (`127.1`, `0x7f.0.0.1`; a bare `0x` is not one) are refused too, because * they are how a literal or `localhost` hides from the rules above (parity decision D13). */ fun isEligibleHost(host: String): Boolean { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt index e763f2c133..2d8f08dda1 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt @@ -66,4 +66,30 @@ class PublicOnlyDnsTest { assertFalse(InetAddress.getByName("64:ff9b::808:808").isPrivate()) assertFalse(InetAddress.getByName("2001:db8::a00:1").isPrivate()) } + + /** Special-use IPv4 blocks beyond the fixture's rows (parity decision D14). */ + @Test + fun `special-use ipv4 ranges are private and their neighbours are not`() { + for (a in listOf("192.0.0.1", "192.0.0.255", "192.0.2.1", "198.18.0.1", "198.19.255.254", "240.0.0.1", "255.255.255.255")) { + assertTrue(InetAddress.getByName(a).isPrivate(), a) + } + for (a in listOf("192.0.1.1", "192.0.3.1", "198.17.255.255", "198.20.0.1", "239.255.255.255".let { "223.255.255.255" })) { + assertFalse(InetAddress.getByName(a).isPrivate(), a) + } + } + + @Test + fun `6to4, ipv4-compatible and mapped ipv6 are judged as the spec says`() { + for (a in listOf("2002::1", "2002:808:808::1", "::", "::1", "::a00:1", "::808:808", "::ffff:10.0.0.1", "::ffff:192.0.2.1")) { + assertTrue(InetAddress.getByName(a).isPrivate(), a) + } + for (a in listOf("::ffff:8.8.8.8", "2001:4860::8888", "2003::1")) { + assertFalse(InetAddress.getByName(a).isPrivate(), a) + } + // Built from raw bytes, so a mapped form is covered whichever class Java returns. + val mapped = ByteArray(16).also { it[10] = 0xFF.toByte(); it[11] = 0xFF.toByte(); it[12] = 10; it[15] = 1 } + assertTrue(InetAddress.getByAddress(mapped).isPrivate()) + mapped[12] = 8; mapped[13] = 8; mapped[14] = 8; mapped[15] = 8 + assertFalse(InetAddress.getByAddress(mapped).isPrivate()) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt index 3a5a3d81bb..f9159e1ce8 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinksTest.kt @@ -76,10 +76,10 @@ class WebLinksTest { /** A trailing dot or an all-numeric host is how an IP literal or `localhost` hides (parity decision D13). */ @Test fun `a trailing dot or a numeric host is refused, a digit in a name is not`() { - for (host in listOf("localhost.", "a.local.", "example.com.", "1.1.1.1.", "127.1", "0x7f.0.0.1", "0177.0.0.1", "0X7F.1", "2130706433", "0x7f000001")) { + for (host in listOf("localhost.", "a.local.", "example.com.", "1.1.1.1.", "127.1", "0x7f.0.0.1", "0177.0.0.1", "0X7F.1", "2130706433", "0x7f000001", "2130706433", "0x1f.0X2")) { assertFalse(WebLinks.isEligibleHost(host), host) } - for (host in listOf("1password.com", "123.example.com", "example.com", "0x.example.com", "a1.b2")) { + for (host in listOf("1password.com", "123.example.com", "example.com", "0x.example.com", "a1.b2", "cafe.be", "0xide.com", "123.example", "0x.0x1")) { assertEquals(true, WebLinks.isEligibleHost(host), host) } } From 3a8ef16ee7d9558330fdbaa02b3581a77df118a1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 16:32:26 -0400 Subject: [PATCH 13/39] fix(messenger): check a redirect's escaped host after resolving it --- .../messenger/internal/link/WebLinkLookup.kt | 2 +- .../app/messenger/internal/link/WebLinks.kt | 24 +++++++++++++++--- .../internal/link/WebLinkLookupTest.kt | 25 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt index a1bcf07562..2e71086524 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -103,7 +103,7 @@ internal class WebLinkLookup( when { response.isRedirect -> { val location = response.header("Location") - if (location == null || WebLinks.hasEscapedHost(location)) { + if (location == null || WebLinks.isUnsafeLocation(location)) { return@runCatching LinkCard.Web.State.None } current = current.resolve(location) ?: return@runCatching LinkCard.Web.State.None diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt index 580834bd36..c741bbca98 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt @@ -17,6 +17,7 @@ internal object WebLinks { private val blockedSuffixes = listOf(".localhost", ".local", ".internal") private val ipv4 = Regex("""^\d{1,3}(\.\d{1,3}){3}$""") + private val schemePrefix = Regex("""^[a-z][a-z0-9+.-]*://""", RegexOption.IGNORE_CASE) private val numericLabel = Regex("""^(0x[0-9a-f]+|\d+)$""", RegexOption.IGNORE_CASE) /** @@ -37,9 +38,26 @@ internal object WebLinks { * decodes the escape and would fetch the decoded host, while iOS keeps it, so both apps give * such a link no card (parity decision D11). */ - fun hasEscapedHost(url: String): Boolean { - val authority = url.substringAfter("://", missingDelimiterValue = "") - .takeWhile { it != '/' && it != '?' && it != '#' } + fun hasEscapedHost(url: String): Boolean = + hasEscape(url.substringAfter("://", missingDelimiterValue = "")) + + /** + * Whether a redirect's `Location` is unusable: it holds a backslash, or it names a host (`scheme://` + * or scheme-relative `//`) with a percent escape. A relative reference keeps the current host, + * so an escape in its path or query is no concern (parity decision D16). + */ + fun isUnsafeLocation(location: String): Boolean { + if ('\\' in location) return true + val rest = when { + location.startsWith("//") -> location.removePrefix("//") + schemePrefix.containsMatchIn(location) -> location.substringAfter("://") + else -> return false + } + return hasEscape(rest) + } + + private fun hasEscape(afterDelimiter: String): Boolean { + val authority = afterDelimiter.takeWhile { it != '/' && it != '?' && it != '#' } return '%' in authority.substringAfterLast('@') } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt index 0a14b50129..df0bd3e1cc 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt @@ -135,6 +135,31 @@ class WebLinkLookupTest { assertEquals(1, script.seen.size) } + /** The escape check reads the Location's own authority, not the first `://` anywhere in it (parity decision D16). */ + @Test + fun `a redirect with an escaped host or a backslash gives none`() { + for (target in listOf( + "//ex%61mple.org/x", + "https://ex%61mple.org/x", + "HTTPS://user@ex%61mple.org:443/x", + "/a\\b", + "https://example.com\\@evil.org/", + )) { + val script = Script { req -> redirect(req, target) } + assertEquals(LinkCard.Web.State.None, fetch(script).state(), target) + assertEquals(1, script.seen.size, target) + } + } + + @Test + fun `a relative redirect keeps its host even when its query holds an escape`() { + val script = Script { req -> + if (req.url.encodedPath == "/redir") reply(req) else redirect(req, "/redir?to=https://a%20b") + } + assertTrue(fetch(script).state() is LinkCard.Web.State.Resolved) + assertEquals(listOf("example.com", "example.com"), script.seen.map { it.url.host }) + } + @Test fun `non html and 404 give none, 503 and io errors fail`() { assertEquals(LinkCard.Web.State.None, fetch(Script { reply(it, type = "application/json") }).state()) From 180ca90c644f313a134d1e84af9010cdd573752e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 16:33:25 -0400 Subject: [PATCH 14/39] fix(messenger): reject conflicting or encoded repeated headers --- .../messenger/internal/link/WebLinkLookup.kt | 14 +++++-- .../internal/link/WebLinkLookupTest.kt | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt index 2e71086524..1c57a7a37f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -100,9 +100,15 @@ internal class WebLinkLookup( return@runCatching LinkCard.Web.State.None } client.newCall(request(current)).await().use { response -> + // Repeated headers that disagree are a smuggling shape; fail rather than pick one (parity decision D17). + if (response.headers.values("Content-Length").distinct().size > 1) { + throw IOException("conflicting Content-Length") + } when { response.isRedirect -> { - val location = response.header("Location") + val locations = response.headers.values("Location").distinct() + if (locations.size > 1) throw IOException("conflicting Location") + val location = locations.firstOrNull() if (location == null || WebLinks.isUnsafeLocation(location)) { return@runCatching LinkCard.Web.State.None } @@ -133,8 +139,10 @@ internal class WebLinkLookup( it.subtype == "html" || it.subtype == "xhtml+xml" } ?: false - private fun Response.isEncoded() = header("Content-Encoding") - ?.let { !it.trim().equals("identity", ignoreCase = true) } ?: false + /** Every value of every repeated Content-Encoding header, each comma-separated token, must be identity. */ + private fun Response.isEncoded() = headers.values("Content-Encoding") + .flatMap { it.split(',') } + .any { !it.trim().equals("identity", ignoreCase = true) } private fun Response.capped(): ByteArray { val source = body.source() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt index df0bd3e1cc..a4c2df8408 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt @@ -57,9 +57,11 @@ class WebLinkLookupTest { type: String? = "text/html; charset=utf-8", body: ByteArray = html.toByteArray(), headers: Map = emptyMap(), + repeated: List> = emptyList(), ): Response = Response.Builder() .request(request).protocol(Protocol.HTTP_1_1).code(code).message("m") .apply { headers.forEach { (k, v) -> header(k, v) } } + .apply { repeated.forEach { (k, v) -> addHeader(k, v) } } .body(body.toResponseBody(type?.toMediaType())) .build() @@ -160,6 +162,44 @@ class WebLinkLookupTest { assertEquals(listOf("example.com", "example.com"), script.seen.map { it.url.host }) } + /** Every Content-Encoding value across repeated headers must be identity (parity decision D17). */ + @Test + fun `any non identity content encoding across repeated headers gives none`() { + for (values in listOf(listOf("gzip", "identity"), listOf("identity", "gzip"), listOf("identity, gzip"), listOf("br"))) { + val script = Script { reply(it, repeated = values.map { v -> "Content-Encoding" to v }) } + assertEquals(LinkCard.Web.State.None, fetch(script).state(), values.toString()) + } + for (values in listOf(listOf("identity"), listOf("Identity", "identity, IDENTITY"))) { + val script = Script { reply(it, repeated = values.map { v -> "Content-Encoding" to v }) } + assertTrue(fetch(script).state() is LinkCard.Web.State.Resolved, values.toString()) + } + } + + @Test + fun `repeated locations that differ fail, equal ones are followed`() { + val differ = Script { req -> + reply(req, 302, null, ByteArray(0), repeated = listOf("Location" to "https://a.example/", "Location" to "https://b.example/")) + } + assertTrue(fetch(differ).isFailure) + assertEquals(1, differ.seen.size) + + val same = Script { req -> + if (req.url.host == "a.example") reply(req) + else reply(req, 302, null, ByteArray(0), repeated = listOf("Location" to "https://a.example/", "Location" to "https://a.example/")) + } + assertTrue(fetch(same).state() is LinkCard.Web.State.Resolved) + assertEquals(2, same.seen.size) + } + + @Test + fun `repeated content lengths that differ fail, equal ones do not`() { + val differ = Script { reply(it, repeated = listOf("Content-Length" to "10", "Content-Length" to "11")) } + assertTrue(fetch(differ).isFailure) + + val same = Script { reply(it, repeated = listOf("Content-Length" to "${html.length}", "Content-Length" to "${html.length}")) } + assertTrue(fetch(same).state() is LinkCard.Web.State.Resolved) + } + @Test fun `non html and 404 give none, 503 and io errors fail`() { assertEquals(LinkCard.Web.State.None, fetch(Script { reply(it, type = "application/json") }).state()) From f4b24a3c69845dd5006d3da2466eeb427c9fb6c5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 16:34:13 -0400 Subject: [PATCH 15/39] feat(messenger): bound a link preview lookup by one deadline --- .../messenger/internal/link/WebLinkLookup.kt | 17 ++++++++++- .../app/messenger/internal/link/WebLinks.kt | 1 + .../internal/link/WebLinkLookupTest.kt | 29 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt index 1c57a7a37f..b0cd30a6c6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -3,10 +3,12 @@ package com.flipcash.app.messenger.internal.link import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.shared.chat.models.LinkCard import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import okhttp3.Call import okhttp3.Callback import okhttp3.CookieJar @@ -24,6 +26,7 @@ import java.net.Proxy import java.net.UnknownHostException import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +import kotlin.time.Duration import kotlin.time.toJavaDuration /** Drops private, loopback and link-local answers, so a public name cannot point the fetch inward. */ @@ -84,10 +87,22 @@ internal class WebLinkLookup( private val client: OkHttpClient, private val enabled: suspend () -> Boolean, private val dispatchers: DispatcherProvider, + private val deadline: Duration = WebLinks.LOOKUP_DEADLINE, ) { private val inFlight = Semaphore(WebLinks.MAX_CONCURRENT) - suspend operator fun invoke(url: String): Result = inFlight.withPermit { fetch(url) } + /** + * One [deadline] covers every hop. It starts once the permit is held, so waiting behind other + * lookups does not spend it. Running out is a failure, not a None: the page may be fine, and a + * failure is not cached (parity decision D18). + */ + suspend operator fun invoke(url: String): Result = inFlight.withPermit { + try { + withTimeout(deadline) { fetch(url) } + } catch (e: TimeoutCancellationException) { + Result.failure(IOException("lookup deadline", e)) + } + } private suspend fun fetch(url: String): Result = withContext(dispatchers.IO) { if (!enabled()) return@withContext Result.failure(IllegalStateException("web previews off")) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt index c741bbca98..f8a1499835 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt @@ -11,6 +11,7 @@ internal object WebLinks { const val MAX_REDIRECTS = 3 const val MAX_CONCURRENT = 4 val TIMEOUT = 5.seconds + val LOOKUP_DEADLINE = 10.seconds val RESOLVED_TTL = 168.hours val EMPTY_TTL = 24.hours const val USER_AGENT = "Mozilla/5.0 (compatible; FlipcashLinkPreview/1.0)" diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt index a4c2df8408..2b4095d92c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.link import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.shared.chat.models.LinkCard +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -32,6 +33,8 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail @@ -200,6 +203,32 @@ class WebLinkLookupTest { assertTrue(fetch(same).state() is LinkCard.Web.State.Resolved) } + /** One deadline covers every hop, and it starts once the permit is held (parity decision D18). */ + @Test + fun `slow hops past the deadline fail and are not none`() { + val script = Script { req -> + Thread.sleep(150) + if (req.url.encodedPath.length < 4) redirect(req, "https://example.com${req.url.encodedPath}x") else reply(req) + } + val slow = WebLinkLookup(webPreviewClient().newBuilder().addInterceptor(script.interceptor).build(), { true }, realIo, deadline = 300.milliseconds) + val result = runBlocking { slow("https://example.com/") } + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() !is CancellationException) + // The same hops inside a longer deadline resolve. + val fast = WebLinkLookup(webPreviewClient().newBuilder().addInterceptor(script.interceptor).build(), { true }, realIo, deadline = 5.seconds) + assertTrue(runBlocking { fast("https://example.com/") }.getOrThrow() is LinkCard.Web.State.Resolved) + } + + @Test + fun `time spent waiting for a permit does not count against the deadline`() { + val script = Script { req -> Thread.sleep(250); reply(req) } + val lookup = WebLinkLookup(webPreviewClient().newBuilder().addInterceptor(script.interceptor).build(), { true }, realIo, deadline = 400.milliseconds) + val results = runBlocking(Dispatchers.IO) { + (1..WebLinks.MAX_CONCURRENT + 1).map { async { lookup("https://example.com/$it") } }.awaitAll() + } + results.forEach { assertTrue(it.getOrThrow() is LinkCard.Web.State.Resolved) } + } + @Test fun `non html and 404 give none, 503 and io errors fail`() { assertEquals(LinkCard.Web.State.None, fetch(Script { reply(it, type = "application/json") }).state()) From 4f95ac9b07b38ef0c018b05ea86a22a89f362f96 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 16:39:38 -0400 Subject: [PATCH 16/39] feat(messenger): resolve and persist web link cards --- .../messenger/internal/link/LinkCardMemory.kt | 9 ++ .../internal/link/LinkCardMemoryModule.kt | 27 ++++++ .../messenger/internal/link/LinkCardModule.kt | 2 + .../internal/link/LinkCardResolver.kt | 29 +++++- .../internal/link/PersistedLinkCardMemory.kt | 49 +++++++++- .../internal/link/LinkCardResolverTest.kt | 71 ++++++++++++++ .../link/PersistedLinkCardMemoryTest.kt | 96 +++++++++++++++++++ .../flipcash/app/featureflags/FeatureFlag.kt | 13 +++ 8 files changed, 291 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt index 18f4b46f27..367b5b12b5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt @@ -31,6 +31,11 @@ internal open class LinkCardMemory { val groups: Map get() = _groups val users: Map get() = _users + /** Web page answers by [WebLinks.cacheKey]. Only answers: a failed lookup is never put here. */ + val webs: Map get() = _webs + + protected val _webs = ConcurrentHashMap() + protected val _tokens = ConcurrentHashMap() protected val _groups = ConcurrentHashMap() protected val _users = ConcurrentHashMap() @@ -39,6 +44,10 @@ internal open class LinkCardMemory { _tokens[mint] = state } + open fun putWeb(key: String, state: LinkCard.Web.State) { + _webs[key] = state + } + open fun putGroup(chatId: ChatId, state: LinkCard.GroupInvite.State.Resolved) { _groups[chatId] = state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt index c2fcb71d57..0cb7c749fa 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt @@ -1,5 +1,7 @@ package com.flipcash.app.messenger.internal.link +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.sources.LinkPreviewDataSource import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager @@ -10,8 +12,15 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import javax.inject.Qualifier import javax.inject.Singleton +/** The client for fetching outside pages: no cookies, public addresses only, bodies not logged. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class WebPreview + /** * The process-wide half of link cards: the answers kept between visits, and the prefetch that * fills them as messages arrive. The per-visit half is [LinkCardModule]. @@ -36,6 +45,24 @@ internal abstract class LinkCardMemoryModule { dispatchers: DispatcherProvider, ): PersistedLinkCardMemory = PersistedLinkCardMemory(store, userManager, resources, dispatchers) + // Not the app's singleton client: it logs bodies at Level.BODY and keeps cookies. + @Provides + @Singleton + @WebPreview + fun provideWebPreviewClient(): OkHttpClient = webPreviewClient() + + @Provides + @Singleton + fun provideWebLinkLookup( + @WebPreview client: OkHttpClient, + flags: FeatureFlagController, + dispatchers: DispatcherProvider, + ): WebLinkLookup = WebLinkLookup( + client = client, + enabled = { flags.get(FeatureFlag.WebLinkPreviews) }, + dispatchers = dispatchers, + ) + @Provides @Singleton fun provideMessageLinkPrefetcher( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt index d7d01b278f..20bcf4f945 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt @@ -35,6 +35,7 @@ internal object LinkCardModule { token: TokenLinkLookup, group: GroupLinkLookup, user: UserLinkLookup, + web: WebLinkLookup, memory: LinkCardMemory, ): LinkCardResolver = LinkCardResolver( @@ -46,6 +47,7 @@ internal object LinkCardModule { tokenMetadata = { token(it) }, group = { group(it) }, user = { user(it) }, + web = web::invoke, memory = memory, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt index 10304038c7..6dc053e1fb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt @@ -51,6 +51,9 @@ internal class LinkCardResolver( private val tokenMetadata: suspend (mint: Mint) -> Result, private val group: suspend (chatId: ChatId) -> Result, private val user: suspend (identity: LinkCard.User.Identity) -> Result, + private val web: suspend (url: String) -> Result = { + Result.failure(IllegalStateException("no web lookup")) + }, private val memory: LinkCardMemory = LinkCardMemory(), ) : LinkCardResolution { @@ -71,6 +74,7 @@ internal class LinkCardResolver( private val tokenQueries = mutableMapOf>() private val groupQueries = mutableMapOf>() private val userQueries = mutableMapOf>() + private val webQueries = mutableMapOf>>() /** * The answers that have landed, readable without the lock and without suspending — which is the @@ -117,8 +121,7 @@ internal class LinkCardResolver( is LinkCard.TokenInfo -> tokenAnswers[card.mint]?.let { card.copy(state = it) } is LinkCard.GroupInvite -> groupAnswers[card.chatId]?.let { card.copy(state = it) } is LinkCard.User -> userAnswers[card.identity]?.let { card.copy(state = it) } - // Task 6 - is LinkCard.Web -> null + is LinkCard.Web -> WebLinks.cacheKey(card.url)?.let { memory.webs[it] }?.let { card.copy(state = it) } } override suspend fun resolve(card: LinkCard): LinkCard = when (card) { @@ -126,8 +129,7 @@ internal class LinkCardResolver( is LinkCard.TokenInfo -> card.copy(state = tokenState(card.mint)) is LinkCard.GroupInvite -> card.copy(state = groupState(card.chatId)) is LinkCard.User -> card.copy(state = userState(card.identity)) - // Task 6 - is LinkCard.Web -> card + is LinkCard.Web -> card.copy(state = webState(card.url) ?: card.state) } /** @@ -264,6 +266,25 @@ internal class LinkCardResolver( ) } + /** + * The answer for a web page, or null when the lookup failed and the card stays as it was. A + * failure is forgotten, never remembered or stored, so the next draw asks again. A page that + * answers is held in [memory] by its cache key, which ignores the fragment. + */ + private suspend fun webState(url: String): LinkCard.Web.State? { + val key = WebLinks.cacheKey(url) ?: return LinkCard.Web.State.None + memory.webs[key]?.let { return it } + return memoized(webQueries, key) { + web(url).also { result -> + result.onSuccess { + memory.putWeb(key, it) + _revision.update { n -> n + 1 } + } + result.onFailure { forget(webQueries, key) } + } + }.getOrNull() + } + private suspend fun memoized( queries: MutableMap>, key: K, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt index 607c30be2f..875633ed76 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt @@ -22,9 +22,10 @@ import kotlinx.serialization.json.Json import java.util.concurrent.ConcurrentHashMap import kotlin.time.Clock import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds /** - * [LinkCardMemory] that also keeps group and person answers in `link_previews`, so the first visit + * [LinkCardMemory] that also keeps group, person and web page answers in `link_previews`, so the first visit * after a cold start paints those cards resolved rather than growing into them. * * Only those two are stored because only those two change size when they resolve: a group card @@ -63,6 +64,7 @@ internal class PersistedLinkCardMemory( loaded.value = false _groups.clear() _users.clear() + _webs.clear() writtenAt.clear() records.forEach { load(it) } loaded.value = true @@ -87,6 +89,12 @@ internal class PersistedLinkCardMemory( write(key, json.encodeToString(UserProfile.serializer(), state.profile.publicOnly())) } + override fun putWeb(key: String, state: LinkCard.Web.State) { + val rowKey = WEB_PREFIX + key + if (_webs.put(key, state) == state && !rewriteDue(rowKey)) return + write(rowKey, webJson.encodeToString(StoredWeb.serializer(), StoredWeb.of(state))) + } + override fun removeGroup(chatId: ChatId) { if (_groups.remove(chatId) != null) delete(groupKey(chatId)) } @@ -133,6 +141,16 @@ internal class PersistedLinkCardMemory( ?.let { _users[identity] = it } ?: error("stored profile names no account") } + record.key.startsWith(WEB_PREFIX) -> { + val stored = webJson.decodeFromString(StoredWeb.serializer(), record.json) + val state = stored.toState() + // Freshness comes from the row itself. A stale row is dropped like one that no + // longer decodes, and the link is looked up again the ordinary way. + val age = (now() - record.updatedAt).milliseconds + val ttl = if (state is LinkCard.Web.State.Resolved) WebLinks.RESOLVED_TTL else WebLinks.EMPTY_TTL + if (age > ttl) error("stale web row") + _webs[record.key.removePrefix(WEB_PREFIX)] = state + } else -> error("unknown key") } }.isSuccess @@ -168,6 +186,33 @@ internal class PersistedLinkCardMemory( } } + /** + * A web card's answer as stored, field names shared with iOS. A null title is + * [LinkCard.Web.State.None]. Every field is written, nulls included. + */ + @Serializable + private data class StoredWeb( + val title: String? = null, + val description: String? = null, + val imageUrl: String? = null, + val host: String? = null, + ) { + fun toState(): LinkCard.Web.State = + if (title == null) { + LinkCard.Web.State.None + } else { + LinkCard.Web.State.Resolved(title, description, imageUrl, host ?: error("web row without a host")) + } + + companion object { + fun of(state: LinkCard.Web.State) = when (state) { + is LinkCard.Web.State.Resolved -> StoredWeb(state.title, state.description, state.imageUrl, state.host) + // Loading is never stored; it stands for no answer, so it is written as none only if asked to. + LinkCard.Web.State.None, LinkCard.Web.State.Loading -> StoredWeb() + } + } + } + @Serializable private data class StoredRequirement( val amount: String?, @@ -181,10 +226,12 @@ internal class PersistedLinkCardMemory( val REWRITE_AFTER = 1.days const val GROUP_PREFIX = "group:" const val USER_PREFIX = "user:" + const val WEB_PREFIX = "web:" const val BY_ID = "id:" const val BY_NAME = "name:" val json = Json { ignoreUnknownKeys = true } + val webJson = Json { ignoreUnknownKeys = true; encodeDefaults = true } fun groupKey(chatId: ChatId) = GROUP_PREFIX + chatId.bytes.toHexString() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt index 87b4ddbf94..0566ee4360 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt @@ -636,4 +636,75 @@ class LinkCardResolverTest { resolver.resolve(userCard.copy(start = 10, end = 38)) assertEquals(1, calls) } + + private val webCard = LinkCard.Web(url = "https://example.com/a#one", start = 0, end = 25) + private val webResolved = LinkCard.Web.State.Resolved( + title = "Example", + description = "A page", + imageUrl = "https://example.com/i.png", + host = "example.com", + ) + + private fun TestScope.webResolver( + memory: LinkCardMemory = LinkCardMemory(), + web: suspend (String) -> Result, + ) = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.failure(IllegalStateException("unused")) }, + tokenMetadata = { Result.failure(IllegalStateException("unused")) }, + group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, + web = web, + memory = memory, + ) + + @Test + fun `peek of an unknown web card is null`() = runTest { + val resolver = webResolver { Result.success(webResolved) } + assertNull(resolver.peek(webCard)) + } + + @Test + fun `a web link is looked up once, even when two cards differ only by fragment`() = runTest { + var calls = 0 + val resolver = webResolver { calls++; Result.success(webResolved) } + assertEquals(webResolved, (resolver.resolve(webCard) as LinkCard.Web).state) + assertEquals(webResolved, (resolver.resolve(webCard.copy(url = "https://example.com/a#two")) as LinkCard.Web).state) + assertEquals(webResolved, (resolver.peek(webCard.copy(url = "https://example.com/a")) as LinkCard.Web).state) + assertEquals(1, calls) + } + + @Test + fun `a failed web lookup is not memoized`() = runTest { + var calls = 0 + val memory = LinkCardMemory() + val resolver = webResolver(memory) { calls++; Result.failure(java.io.IOException("offline")) } + assertEquals(LinkCard.Web.State.Loading, (resolver.resolve(webCard) as LinkCard.Web).state) + assertNull(resolver.peek(webCard)) + assertTrue(memory.webs.isEmpty()) + val revision = resolver.revision.value + resolver.resolve(webCard) + assertEquals(2, calls) + assertEquals(revision, resolver.revision.value) + } + + @Test + fun `none and resolved web answers are memoized and bump the revision`() = runTest { + for (answer in listOf(LinkCard.Web.State.None, webResolved)) { + val resolver = webResolver { Result.success(answer) } + val before = resolver.revision.value + resolver.resolve(webCard) + assertEquals(before + 1, resolver.revision.value) + assertEquals(answer, (resolver.peek(webCard) as LinkCard.Web).state) + } + } + + @Test + fun `a web link that cannot be keyed resolves to none without a lookup`() = runTest { + var calls = 0 + val resolver = webResolver { calls++; Result.success(webResolved) } + val bad = webCard.copy(url = "not a url") + assertEquals(LinkCard.Web.State.None, (resolver.resolve(bad) as LinkCard.Web).state) + assertEquals(0, calls) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt index 603ca56196..8b3bceb362 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt @@ -226,4 +226,100 @@ class PersistedLinkCardMemoryTest { assertNull(memory.groups[chatId]) } + + private val webResolved = LinkCard.Web.State.Resolved( + title = "Example", + description = null, + imageUrl = "https://example.com/i.png", + host = "example.com", + ) + private val webKey = "https://example.com/a" + + @Test + fun `a web answer round-trips under a web key with the agreed fields`() = runTest { + val table = FakeTable() + memory(table).apply { + putWeb(webKey, webResolved) + putWeb("https://example.com/none", LinkCard.Web.State.None) + } + advanceUntilIdle() + assertEquals( + """{"title":"Example","description":null,"imageUrl":"https://example.com/i.png","host":"example.com"}""", + table.rows["web:$webKey"]!!.json, + ) + assertEquals( + """{"title":null,"description":null,"imageUrl":null,"host":null}""", + table.rows["web:https://example.com/none"]!!.json, + ) + + table.reopen() + val restarted = memory(table) + advanceUntilIdle() + restarted.awaitLoaded() + assertEquals(webResolved, restarted.webs[webKey]) + assertEquals(LinkCard.Web.State.None, restarted.webs["https://example.com/none"]) + } + + private suspend fun TestScope.webReloaded(state: LinkCard.Web.State, ageMs: Long): LinkCardMemory { + val table = FakeTable() + memory(table).putWeb(webKey, state) + advanceUntilIdle() + clock += ageMs + table.reopen() + val restarted = memory(table) + advanceUntilIdle() + restarted.awaitLoaded() + return restarted + } + + @Test + fun `a resolved web row reads as absent only once it is older than the resolved TTL`() = runTest { + val ttl = 168.hours.inWholeMilliseconds + assertEquals(webResolved, webReloaded(webResolved, ttl).webs[webKey]) + assertNull(webReloaded(webResolved, ttl + 1).webs[webKey]) + } + + @Test + fun `a none web row reads as absent only once it is older than the empty TTL`() = runTest { + val ttl = 24.hours.inWholeMilliseconds + assertEquals(LinkCard.Web.State.None, webReloaded(LinkCard.Web.State.None, ttl).webs[webKey]) + assertNull(webReloaded(LinkCard.Web.State.None, ttl + 1).webs[webKey]) + } + + @Test + fun `a none web row is not kept for the resolved TTL`() = runTest { + assertNull(webReloaded(LinkCard.Web.State.None, 25.hours.inWholeMilliseconds).webs[webKey]) + assertEquals(webResolved, webReloaded(webResolved, 25.hours.inWholeMilliseconds).webs[webKey]) + } + + @Test + fun `an unchanged web answer is not written again within a day`() = runTest { + val table = FakeTable() + val memory = memory(table) + advanceUntilIdle() + memory.awaitLoaded() + memory.putWeb(webKey, webResolved) + advanceUntilIdle() + memory.putWeb(webKey, webResolved) + advanceUntilIdle() + assertEquals(1, table.writes.count { it == "web:$webKey" }) + } + + @Test + fun `a failed web lookup through the resolver leaves nothing on disk`() = runTest { + val table = FakeTable() + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.failure(IllegalStateException("unused")) }, + tokenMetadata = { Result.failure(IllegalStateException("unused")) }, + group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, + web = { Result.failure(java.io.IOException("offline")) }, + memory = memory(table), + ) + resolver.resolve(LinkCard.Web(url = webKey, start = 0, end = 21)) + advanceUntilIdle() + assertTrue(table.writes.isEmpty()) + assertTrue(table.rows.isEmpty()) + } } diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 00e330d8dc..169f53eb8f 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -88,6 +88,17 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } + @FeatureFlagMarker + data object WebLinkPreviews : FeatureFlag { + override val key: String = "web_link_previews" + override val default: Boolean = true + // Not launched: a launched flag is forced to its default and its toggle is hidden + // (InternalFeatureFlagController.get), which would leave no way to turn previews off. + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = false + } + companion object { val entries: List> get() = FeatureFlagEntries.entries @@ -107,6 +118,7 @@ val FeatureFlag<*>.title: String FeatureFlag.BillTextures -> "Bill Textures" FeatureFlag.BackgroundReset -> "Background Reset" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" + FeatureFlag.WebLinkPreviews -> "Web Link Previews" } val FeatureFlag<*>.message: String @@ -117,4 +129,5 @@ val FeatureFlag<*>.message: String FeatureFlag.BillTextures -> "When enabled, you'll gain the ability to select textures for bills during currency creation" FeatureFlag.BackgroundReset -> "Automatically returns the app to the camera screen after a period of inactivity with the app in the background" FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" + FeatureFlag.WebLinkPreviews -> "When enabled, a link to a web page in a chat shows the page's title, description and image, fetched from this device" } From 92b3962cd28e76a95dcd0fdc92931ca46603bfc2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 16:57:55 -0400 Subject: [PATCH 17/39] feat(chat): prefetch web links only for the open chat's members --- .../internal/link/LinkCardMemoryModule.kt | 2 + .../internal/link/MessageLinkPrefetcher.kt | 19 +++-- .../link/MessageLinkPrefetcherTest.kt | 45 +++++++++- .../shared/chat/MessageLinkPrefetch.kt | 9 +- .../shared/chat/internal/WebPreviewGate.kt | 27 ++++++ .../internal/delegates/EventStreamDelegate.kt | 7 +- .../internal/delegates/MessagingDelegate.kt | 5 +- .../shared/chat/MessagingWebPrefetchTest.kt | 84 +++++++++++++++++++ .../shared/chat/WebPreviewGateTest.kt | 77 +++++++++++++++++ .../sources/ChatMetadataDataSource.kt | 4 + 10 files changed, 266 insertions(+), 13 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/WebPreviewGate.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingWebPrefetchTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/WebPreviewGateTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt index 0cb7c749fa..38e7aafee4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt @@ -70,12 +70,14 @@ internal abstract class LinkCardMemoryModule { memory: LinkCardMemory, group: GroupLinkLookup, user: UserLinkLookup, + web: WebLinkLookup, dispatchers: DispatcherProvider, ): MessageLinkPrefetcher = MessageLinkPrefetcher( classifier = classifier, memory = memory, group = { group(it) }, user = { user(it) }, + web = { web(it) }, dispatchers = dispatchers, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt index db46861208..bde3a23fc2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt @@ -21,8 +21,8 @@ import kotlin.time.Duration.Companion.milliseconds * Resolves the group and person links in arriving messages into [LinkCardMemory], so a card is * drawn resolved on the frame its message first appears. See [MessageLinkPrefetch]. * - * Only those two card kinds are fetched ahead: they are the ones whose size depends on the answer. - * Cash and token cards keep resolving when drawn, as before. + * Group and person links are always fetched ahead; outside pages only when the caller passes + * `webLinks`. Cash and token cards keep resolving when drawn, as before. * * Links are found exactly as the transcript finds them -- the same text, the same detection pass, * the same classifier -- so a link prefetched here is the link a card is drawn for. @@ -32,6 +32,7 @@ internal class MessageLinkPrefetcher( private val memory: LinkCardMemory, private val group: suspend (chatId: ChatId) -> Result, private val user: suspend (identity: LinkCard.User.Identity) -> Result, + private val web: suspend (url: String) -> Result, dispatchers: DispatcherProvider, ) : MessageLinkPrefetch { @@ -40,7 +41,7 @@ internal class MessageLinkPrefetcher( /** In-flight lookups by link, so a link in two arriving batches is asked for once. */ private val inFlight = ConcurrentHashMap>() - override suspend fun prefetch(messages: List, wait: Duration) { + override suspend fun prefetch(messages: List, wait: Duration, webLinks: Boolean) { // A lookup for a link the store already answers would be wasted, and on a cold start the // store may still be loading; wait briefly for it rather than asking for everything. withTimeoutOrNull(LOAD_WAIT) { memory.awaitLoaded() } @@ -50,22 +51,26 @@ internal class MessageLinkPrefetcher( .filterNot { it.redacted } .flatMap { it.content.asSequence() } .mapNotNull { content -> content.linkableText()?.let { classifier.firstCard(detectUrls(it)) } } - .mapNotNull { card -> lookup(card) } + .mapNotNull { card -> lookup(card, webLinks) } .toList() if (lookups.isEmpty() || wait <= Duration.ZERO) return withTimeoutOrNull(wait) { lookups.awaitAll() } } - private fun lookup(card: LinkCard): Deferred? = when (card) { + private fun lookup(card: LinkCard, webLinks: Boolean): Deferred? = when (card) { is LinkCard.GroupInvite -> card.chatId .takeUnless { it in memory.groups } ?.let { chatId -> start(chatId) { group(chatId).onSuccess { memory.putGroup(chatId, it) } } } is LinkCard.User -> card.identity .takeUnless { it in memory.users } ?.let { identity -> start(identity) { user(identity).onSuccess { memory.putUser(identity, it) } } } - // Web lookups arrive in Task 7. - is LinkCard.Cash, is LinkCard.TokenInfo, is LinkCard.Web -> null + // An outside fetch shows the linked host who is reading, so only a caller that vouched for + // the open chat and its member asks for it. Otherwise the card resolves when drawn. + is LinkCard.Web -> if (!webLinks) null else WebLinks.cacheKey(card.url) + ?.takeUnless { it in memory.webs } + ?.let { key -> start(key) { web(card.url).onSuccess { memory.putWeb(key, it) } } } + is LinkCard.Cash, is LinkCard.TokenInfo -> null } private fun start(key: Any, block: suspend () -> Unit): Deferred = diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt index 469715862f..f874f05840 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt @@ -66,12 +66,14 @@ class MessageLinkPrefetcherTest { private fun TestScope.prefetcher( memory: LinkCardMemory, - group: suspend (ChatId) -> Result, + web: suspend (String) -> Result = { Result.failure(IOException("not used")) }, + group: suspend (ChatId) -> Result = { Result.failure(IOException("not used")) }, ) = MessageLinkPrefetcher( classifier = LinkCardClassifier(router), memory = memory, group = group, user = { Result.failure(IOException("not used")) }, + web = web, dispatchers = TestDispatcherProvider(StandardTestDispatcher(testScheduler)), ) @@ -134,4 +136,45 @@ class MessageLinkPrefetcherTest { assertEquals(0, asked) } + + private val pageUrl = "https://example.com/article" + private val pageText = "read $pageUrl" + private val page = LinkCard.Web.State.Resolved( + title = "An article", + description = null, + imageUrl = null, + host = "example.com", + ) + + @Test + fun `a web link is not fetched unless the caller allows it`() = runTest { + val memory = LinkCardMemory() + var asked = 0 + prefetcher(memory, web = { asked++; Result.success(page) }) + .prefetch(listOf(message(pageText)), wait = 1.seconds) + + assertEquals(0, asked) + assertTrue(memory.webs.isEmpty()) + } + + @Test + fun `a web link is fetched once when allowed, and not again once held`() = runTest { + val memory = LinkCardMemory() + var asked = 0 + val prefetcher = prefetcher(memory, web = { asked++; Result.success(page) }) + prefetcher.prefetch(listOf(message(pageText)), wait = 1.seconds, webLinks = true) + prefetcher.prefetch(listOf(message(pageText, id = 2)), wait = 1.seconds, webLinks = true) + + assertEquals(1, asked) + assertEquals(page, memory.webs[WebLinks.cacheKey(pageUrl)]) + } + + @Test + fun `a group invite is prefetched whether or not web links are allowed`() = runTest { + val memory = LinkCardMemory() + prefetcher(memory, group = { Result.success(resolved) }) + .prefetch(listOf(message(inviteText)), wait = 1.seconds, webLinks = false) + + assertEquals(resolved, memory.groups[chatId]) + } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageLinkPrefetch.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageLinkPrefetch.kt index 8173789e4b..550bd6ab0f 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageLinkPrefetch.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageLinkPrefetch.kt @@ -14,6 +14,11 @@ import kotlin.time.Duration.Companion.seconds * * The lookups live in `:apps:flipcash:features:messenger`, which this module cannot see, so this is * the seam; `MessageLinkPrefetcher` on the far side is the implementation. + * + * [webLinks] lets outside https links be fetched too. Only a caller that knows the messages are in + * the chat on screen, and that the viewer is a member of it, passes true. A web fetch is visible + * to the linked host, so it must happen no earlier than the read receipt it stands beside. A feed + * sync, a delivery or a push never passes it. */ interface MessageLinkPrefetch { @@ -27,11 +32,11 @@ interface MessageLinkPrefetch { * * Never throws: a lookup that fails leaves the card to ask for itself when drawn. */ - suspend fun prefetch(messages: List, wait: Duration = Duration.ZERO) + suspend fun prefetch(messages: List, wait: Duration = Duration.ZERO, webLinks: Boolean = false) /** Does nothing. What a delegate built without a prefetcher -- a unit test -- is given. */ object None : MessageLinkPrefetch { - override suspend fun prefetch(messages: List, wait: Duration) = Unit + override suspend fun prefetch(messages: List, wait: Duration, webLinks: Boolean) = Unit } companion object { diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/WebPreviewGate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/WebPreviewGate.kt new file mode 100644 index 0000000000..68ac813c8b --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/WebPreviewGate.kt @@ -0,0 +1,27 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType + +/** + * Whether a chat's web links may be fetched now: it is the chat on screen, and the viewer may read + * it. `activeChat` is null while the app is backgrounded, so a push that arrives then is refused. + * + * A DM is always allowed and its membership is not read. Only a group has non-members who can be + * looking at a preview, so only a group is checked. A chat whose type is not stored yet is refused: + * it may be a group. + */ +internal class WebPreviewGate( + private val stateHolder: ChatStateHolder, + private val metadata: ChatMetadataDataSource, +) { + suspend fun allows(chatId: ChatId): Boolean { + if (stateHolder.current.activeChat != chatId) return false + return when (metadata.getChatType(chatId)) { + ChatType.CONTACT_DM, ChatType.TIP_DM -> true + ChatType.GROUP -> metadata.isMember(chatId) + ChatType.UNKNOWN -> false + } + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 467f01a732..5888150319 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -29,6 +29,7 @@ import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.MessageLinkPrefetch import com.flipcash.shared.chat.internal.MentionPoolUpdates +import com.flipcash.shared.chat.internal.WebPreviewGate import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.core.ID import com.getcode.utils.TraceType @@ -96,6 +97,8 @@ class EventStreamDelegate @Inject constructor( private val mentionPool: MentionPoolUpdates = MentionPoolUpdates.None, ) : EventStreamOperations { + private val webGate = WebPreviewGate(stateHolder, metadataDataSource) + companion object { private const val TAG = "EventStreamDelegate" private val GAP_FILL_DELAY = 2.seconds @@ -293,7 +296,7 @@ class EventStreamDelegate @Inject constructor( result .onSuccess { delta -> if (delta.messages.isNotEmpty()) { - linkPrefetch.prefetch(delta.messages, MessageLinkPrefetch.LIVE_WAIT) + linkPrefetch.prefetch(delta.messages, MessageLinkPrefetch.LIVE_WAIT, webLinks = webGate.allows(chatId)) messageDataSource.upsert(chatId, delta.messages) } // One write that only moves forward: the cursor, and the newest message only @@ -355,7 +358,7 @@ class EventStreamDelegate @Inject constructor( val lastMsg = if (resolvedMessages.isNotEmpty()) { trace(tag = TAG, message = "Upserting ${resolvedMessages.size} messages for $chatId", type = TraceType.Process) // Waited on so a link card lands already sized; see [MessageLinkPrefetch]. - linkPrefetch.prefetch(resolvedMessages, MessageLinkPrefetch.LIVE_WAIT) + linkPrefetch.prefetch(resolvedMessages, MessageLinkPrefetch.LIVE_WAIT, webLinks = webGate.allows(update.chatId)) messageDataSource.upsert(chatId, resolvedMessages) typingTracker.messageArrived(chatId, resolvedMessages.mapNotNull { it.senderId }) mentionPool.onMessages(chatId, resolvedMessages.mapNotNull { m -> m.senderId?.let { it to m.timestamp } }) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index f112d91418..6d906e9ed0 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -43,6 +43,7 @@ import com.flipcash.shared.chat.PendingMutation import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.shared.chat.internal.OutgoingEncryption +import com.flipcash.shared.chat.internal.WebPreviewGate import com.flipcash.shared.chat.media.ChatMediaText import com.flipcash.shared.chat.replacingText import com.flipcash.services.user.UserManager @@ -99,6 +100,8 @@ class MessagingDelegate @Inject constructor( private val incoming: IncomingMessageOpener? = null, ) : MessagingOperations { + private val webGate = WebPreviewGate(stateHolder, metadataDataSource) + /** * Edits and deletes awaiting a server answer, per chat, keyed by message id. * @@ -262,7 +265,7 @@ class MessagingDelegate @Inject constructor( messagingController.getMessages(chatId) .onSuccess { messages -> // Not waited on: this page is what the open transcript is waiting for. - linkPrefetch.prefetch(messages) + linkPrefetch.prefetch(messages, webLinks = webGate.allows(chatId)) messageDataSource.upsert(chatId, messages) // Seat the event-log cursor at the newest page's frontier. It is what marks this diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingWebPrefetchTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingWebPrefetchTest.kt new file mode 100644 index 0000000000..c9cac2d949 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingWebPrefetchTest.kt @@ -0,0 +1,84 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Instant + +/** + * Which page loads may fetch outside links. Every load still prefetches, so group, person and gift + * cards resolve ahead as before; only the `webLinks` grant depends on the chat. + */ +class MessagingWebPrefetchTest { + + private val chatId = ChatId("aabbccdd") + private val page = ChatMessage( + messageId = 1, + senderId = listOf(4, 5, 6), + content = listOf(MessageContent.Text("https://example.com/a")), + timestamp = Instant.fromEpochSeconds(1001), + unreadSeq = 0, + ) + + private class Recorder : MessageLinkPrefetch { + val grants = mutableListOf() + override suspend fun prefetch(messages: List, wait: Duration, webLinks: Boolean) { + grants += webLinks + } + } + + private suspend fun load(open: ChatId?, type: ChatType, isMember: Boolean): List { + val messagingController = mockk(relaxed = true) + coEvery { messagingController.getMessages(chatId, any()) } returns Result.success(listOf(page)) + val metadata = mockk(relaxed = true) + coEvery { metadata.getChatType(chatId) } returns type + coEvery { metadata.isMember(chatId) } returns isMember + val state = ChatStateHolder().apply { update { it.copy(activeChat = open) } } + val recorder = Recorder() + MessagingDelegate( + chatController = mockk(relaxed = true), + messagingController = messagingController, + metadataDataSource = metadata, + messageDataSource = mockk(relaxed = true), + memberDataSource = mockk(relaxed = true), + notificationManager = mockk(relaxed = true), + userManager = mockk(relaxed = true), + stateHolder = state, + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + linkPrefetch = recorder, + ).loadMessages(chatId) + return recorder.grants + } + + @Test + fun `the open group's member gets web links`() = runTest { + assertEquals(listOf(true), load(open = chatId, type = ChatType.GROUP, isMember = true)) + } + + @Test + fun `the open group's non-member still prefetches, without web links`() = runTest { + assertEquals(listOf(false), load(open = chatId, type = ChatType.GROUP, isMember = false)) + } + + @Test + fun `an open DM gets web links`() = runTest { + assertEquals(listOf(true), load(open = chatId, type = ChatType.CONTACT_DM, isMember = false)) + } + + @Test + fun `a chat that is not open still prefetches, without web links`() = runTest { + assertEquals(listOf(false), load(open = null, type = ChatType.CONTACT_DM, isMember = true)) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/WebPreviewGateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/WebPreviewGateTest.kt new file mode 100644 index 0000000000..5d9d877146 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/WebPreviewGateTest.kt @@ -0,0 +1,77 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.WebPreviewGate +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WebPreviewGateTest { + + private val chatId = ChatId(ByteArray(16) { it.toByte() }) + private val other = ChatId(ByteArray(16) { (it + 1).toByte() }) + + private val stateHolder = ChatStateHolder() + private val metadata = mockk() + private val gate = WebPreviewGate(stateHolder, metadata) + + private fun open(chat: ChatId?) = stateHolder.update { it.copy(activeChat = chat) } + + private fun stored(type: ChatType, isMember: Boolean = true) { + coEvery { metadata.getChatType(chatId) } returns type + coEvery { metadata.isMember(chatId) } returns isMember + } + + @Test + fun `a chat that is not the open one is refused`() = runTest { + stored(ChatType.CONTACT_DM) + open(other) + assertFalse(gate.allows(chatId)) + } + + @Test + fun `nothing open refuses every chat`() = runTest { + stored(ChatType.CONTACT_DM) + open(null) + assertFalse(gate.allows(chatId)) + } + + @Test + fun `the open group is refused to a non-member`() = runTest { + stored(ChatType.GROUP, isMember = false) + open(chatId) + assertFalse(gate.allows(chatId)) + } + + @Test + fun `the open group is allowed to a member`() = runTest { + stored(ChatType.GROUP, isMember = true) + open(chatId) + assertTrue(gate.allows(chatId)) + } + + @Test + fun `an open DM is allowed without reading membership`() = runTest { + stored(ChatType.CONTACT_DM, isMember = false) + open(chatId) + assertTrue(gate.allows(chatId)) + + stored(ChatType.TIP_DM, isMember = false) + assertTrue(gate.allows(chatId)) + coVerify(exactly = 0) { metadata.isMember(any()) } + } + + @Test + fun `an open chat of unknown type is refused`() = runTest { + stored(ChatType.UNKNOWN) + open(chatId) + assertFalse(gate.allows(chatId)) + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index fa1ca43f45..8bc485cb35 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -47,6 +47,10 @@ class ChatMetadataDataSource @Inject constructor( suspend fun getChatIdsOfType(chatType: ChatType): List = db?.chatMetadataDao()?.getChatIdsOfType(chatType.name).orEmpty() + /** Whether this device still counts the viewer a member of [chatId]; false when it is not stored. */ + suspend fun isMember(chatId: ChatId): Boolean = + db?.chatMetadataDao()?.getById(mapper.chatIdHex(chatId))?.isMember ?: false + suspend fun setMembership(chatId: ChatId, isMember: Boolean) { setMembership(mapper.chatIdHex(chatId), isMember) } From 53d11f1b1ee944b2e308176e872a866763f6b0ac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 17:00:17 -0400 Subject: [PATCH 18/39] fix(messenger): expire remembered web cards by their ttl --- .../messenger/internal/link/LinkCardMemory.kt | 40 ++++++++++++++++--- .../internal/link/LinkCardResolver.kt | 4 +- .../internal/link/PersistedLinkCardMemory.kt | 10 +++-- .../internal/link/LinkCardResolverTest.kt | 30 ++++++++++++++ .../link/PersistedLinkCardMemoryTest.kt | 10 +++++ 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt index 367b5b12b5..0d54357f45 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt @@ -26,15 +26,45 @@ import java.util.concurrent.ConcurrentHashMap * The maps are read directly -- [LinkCardResolver.peek] runs during composition and must not * suspend -- and written only through the `put` functions, so a subclass sees every answer. */ -internal open class LinkCardMemory { +internal open class LinkCardMemory( + protected val clock: () -> Long = { System.currentTimeMillis() }, +) { val tokens: Map get() = _tokens val groups: Map get() = _groups val users: Map get() = _users - /** Web page answers by [WebLinks.cacheKey]. Only answers: a failed lookup is never put here. */ - val webs: Map get() = _webs + /** + * Web page answers by [WebLinks.cacheKey]. Only answers: a failed lookup is never put here. An + * answer past its TTL reads as absent (parity decision D19), so it is looked up again. + */ + val webs: Map = FreshWebs() + + private class Stored(val state: LinkCard.Web.State, val at: Long) + + private val _webEntries = ConcurrentHashMap() + + private fun Stored.isFresh(): Boolean { + val ttl = if (state is LinkCard.Web.State.Resolved) WebLinks.RESOLVED_TTL else WebLinks.EMPTY_TTL + return clock() - at <= ttl.inWholeMilliseconds + } + + private inner class FreshWebs : AbstractMap() { + override val entries: Set> + get() = _webEntries.entries.filter { it.value.isFresh() } + .associate { it.key to it.value.state }.entries + + override fun get(key: String): LinkCard.Web.State? = + _webEntries[key]?.takeIf { it.isFresh() }?.state + + override fun containsKey(key: String): Boolean = get(key) != null + } + + /** Holds [state] as of [at]: now for an answer just fetched, the row's `updatedAt` for a loaded one. */ + protected fun storeWeb(key: String, state: LinkCard.Web.State, at: Long = clock()) { + _webEntries[key] = Stored(state, at) + } - protected val _webs = ConcurrentHashMap() + protected fun clearWebs() = _webEntries.clear() protected val _tokens = ConcurrentHashMap() protected val _groups = ConcurrentHashMap() @@ -45,7 +75,7 @@ internal open class LinkCardMemory { } open fun putWeb(key: String, state: LinkCard.Web.State) { - _webs[key] = state + storeWeb(key, state) } open fun putGroup(chatId: ChatId, state: LinkCard.GroupInvite.State.Resolved) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt index 6dc053e1fb..acfc173473 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt @@ -280,7 +280,9 @@ internal class LinkCardResolver( memory.putWeb(key, it) _revision.update { n -> n + 1 } } - result.onFailure { forget(webQueries, key) } + // Memory holds a success, and expires it by TTL. Keeping the query too would + // hand back the expired answer instead of asking again. + forget(webQueries, key) } }.getOrNull() } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt index 875633ed76..6cb0d7741a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt @@ -50,7 +50,7 @@ internal class PersistedLinkCardMemory( private val resources: ResourceHelper, dispatchers: DispatcherProvider, private val now: () -> Long = { Clock.System.now().toEpochMilliseconds() }, -) : LinkCardMemory() { +) : LinkCardMemory(clock = now) { private val scope = CoroutineScope(SupervisorJob() + dispatchers.IO) private val loaded = MutableStateFlow(false) @@ -64,7 +64,7 @@ internal class PersistedLinkCardMemory( loaded.value = false _groups.clear() _users.clear() - _webs.clear() + clearWebs() writtenAt.clear() records.forEach { load(it) } loaded.value = true @@ -91,7 +91,9 @@ internal class PersistedLinkCardMemory( override fun putWeb(key: String, state: LinkCard.Web.State) { val rowKey = WEB_PREFIX + key - if (_webs.put(key, state) == state && !rewriteDue(rowKey)) return + val unchanged = webs[key] == state + storeWeb(key, state) + if (unchanged && !rewriteDue(rowKey)) return write(rowKey, webJson.encodeToString(StoredWeb.serializer(), StoredWeb.of(state))) } @@ -149,7 +151,7 @@ internal class PersistedLinkCardMemory( val age = (now() - record.updatedAt).milliseconds val ttl = if (state is LinkCard.Web.State.Resolved) WebLinks.RESOLVED_TTL else WebLinks.EMPTY_TTL if (age > ttl) error("stale web row") - _webs[record.key.removePrefix(WEB_PREFIX)] = state + storeWeb(record.key.removePrefix(WEB_PREFIX), state, at = record.updatedAt) } else -> error("unknown key") } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt index 0566ee4360..8b3cbdc755 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt @@ -707,4 +707,34 @@ class LinkCardResolverTest { assertEquals(LinkCard.Web.State.None, (resolver.resolve(bad) as LinkCard.Web).state) assertEquals(0, calls) } + + private suspend fun TestScope.expiry(answer: LinkCard.Web.State, ttl: kotlin.time.Duration) { + var now = 1_000_000L + var calls = 0 + val memory = LinkCardMemory(clock = { now }) + val resolver = webResolver(memory) { calls++; Result.success(answer) } + resolver.resolve(webCard) + assertEquals(1, calls) + + now += ttl.inWholeMilliseconds + assertEquals(answer, (resolver.peek(webCard) as LinkCard.Web).state) + resolver.resolve(webCard) + assertEquals(1, calls) + + now += 1 + assertNull(resolver.peek(webCard)) + assertTrue(memory.webs.isEmpty()) + assertEquals(answer, (resolver.resolve(webCard) as LinkCard.Web).state) + assertEquals(2, calls) + } + + @Test + fun `a none web answer expires in memory at the empty ttl and is looked up again`() = runTest { + expiry(LinkCard.Web.State.None, WebLinks.EMPTY_TTL) + } + + @Test + fun `a resolved web answer expires in memory at the resolved ttl and is looked up again`() = runTest { + expiry(webResolved, WebLinks.RESOLVED_TTL) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt index 8b3bceb362..8fccaafc69 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt @@ -322,4 +322,14 @@ class PersistedLinkCardMemoryTest { assertTrue(table.writes.isEmpty()) assertTrue(table.rows.isEmpty()) } + + @Test + fun `an entry loaded from a 23 hour old none row expires an hour and a millisecond later`() = runTest { + val loaded = webReloaded(LinkCard.Web.State.None, 23.hours.inWholeMilliseconds) + assertEquals(LinkCard.Web.State.None, loaded.webs[webKey]) + clock += 1.hours.inWholeMilliseconds + assertEquals(LinkCard.Web.State.None, loaded.webs[webKey]) + clock += 1 + assertNull(loaded.webs[webKey]) + } } From b269a2953f23c56474f0c4a9d2567affa68abdfb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 17:31:04 -0400 Subject: [PATCH 19/39] feat(messenger): fetch link preview images under the page rules --- .../features/messenger/build.gradle.kts | 3 + .../internal/link/LinkCardMemoryModule.kt | 24 ++ .../messenger/internal/link/WebImageClient.kt | 94 +++++ .../messenger/internal/link/WebImageLoader.kt | 19 + .../messenger/internal/link/WebLinkLookup.kt | 53 +-- .../internal/link/WebImageClientTest.kt | 355 ++++++++++++++++++ 6 files changed, 527 insertions(+), 21 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageClient.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageClientTest.kt diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 241749dcc6..c1d65b0e14 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -44,6 +44,9 @@ dependencies { implementation(libs.kotlinx.serialization.json) // HttpUrl parses and normalises outside links (WebLinks). implementation(libs.okhttp) + // The preview picture's own ImageLoader (WebImageLoader), on the client built under the page rules. + implementation(libs.coil3.core) + implementation(libs.coil3.network) testImplementation(libs.bundles.unit.testing) testImplementation(libs.mockito.kotlin) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt index 38e7aafee4..78f0b6949d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt @@ -1,5 +1,7 @@ package com.flipcash.app.messenger.internal.link +import android.content.Context +import coil3.ImageLoader import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.sources.LinkPreviewDataSource @@ -11,6 +13,8 @@ import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import kotlinx.coroutines.runBlocking +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import javax.inject.Qualifier @@ -21,6 +25,11 @@ import javax.inject.Singleton @Retention(AnnotationRetention.BINARY) internal annotation class WebPreview +/** The client for the picture a page names: the page rules plus a cap on the body, see [webImageClient]. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class WebPreviewImages + /** * The process-wide half of link cards: the answers kept between visits, and the prefetch that * fills them as messages arrive. The per-visit half is [LinkCardModule]. @@ -51,6 +60,21 @@ internal abstract class LinkCardMemoryModule { @WebPreview fun provideWebPreviewClient(): OkHttpClient = webPreviewClient() + @Provides + @Singleton + @WebPreviewImages + fun provideWebImageClient(flags: FeatureFlagController): OkHttpClient = + // The interceptor runs on OkHttp's own thread, where blocking for a flag read is fine. + webImageClient(enabled = { runBlocking { flags.get(FeatureFlag.WebLinkPreviews) } }) + + @Provides + @Singleton + @WebPreviewImages + fun provideWebPreviewImageLoader( + @ApplicationContext context: Context, + @WebPreviewImages client: OkHttpClient, + ): ImageLoader = webPreviewImageLoader(context, client) + @Provides @Singleton fun provideWebLinkLookup( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageClient.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageClient.kt new file mode 100644 index 0000000000..3496b8328e --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageClient.kt @@ -0,0 +1,94 @@ +package com.flipcash.app.messenger.internal.link + +import okhttp3.Dns +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.asResponseBody +import okio.Buffer +import okio.ForwardingSource +import okio.buffer +import java.io.IOException + +/** + * The client for a preview's picture, built under the same rules as [webPreviewClient]: no cookies, + * no cache, no system proxy, [PublicOnlyDns], and no redirect followed by OkHttp itself. + * [WebImageInterceptor] follows them by hand and holds the image to the page rules (decision 4). + * + * [enabled] is the kill switch: off, the call fails without a request. + */ +internal fun webImageClient(dns: Dns = PublicOnlyDns(), enabled: () -> Boolean = { true }): OkHttpClient = + webClientBuilder(dns) + .addInterceptor(WebImageInterceptor(enabled)) + .build() + +/** + * An application interceptor, so it decides every hop itself: OkHttp's follow-up sits below it and + * must stay off, or `proceed` would hand back the last hop with the checks skipped. + * + * The request is rebuilt for each hop, so what the caller set (cookies, credentials, a different + * `Accept-Encoding`) is not sent. A failure is an [IOException], which the loader treats as "no + * image"; the card then draws without one. + */ +internal class WebImageInterceptor(private val enabled: () -> Boolean) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + if (!enabled()) throw IOException("web previews off") + var current = chain.request().url + // The first request plus MAX_REDIRECTS redirects, as for pages. + repeat(WebLinks.MAX_REDIRECTS + 1) { + if (!current.isFetchable()) throw IOException("image url not fetchable") + val response = chain.proceed(request(current)) + if (!response.isRedirect) return response.checkedImage() + current = response.use { + it.requireConsistentLength() + it.redirectTarget(current) + } ?: throw IOException("unusable image redirect") + } + throw IOException("too many image redirects") + } + + // Set on the request, so OkHttp's bridge sees Accept-Encoding and leaves the body encoded. + private fun request(url: HttpUrl) = Request.Builder().url(url) + .header("User-Agent", WebLinks.USER_AGENT) + .header("Accept", "image/*") + .header("Accept-Encoding", "identity") + .build() + + private fun Response.checkedImage(): Response { + try { + requireConsistentLength() + if (!isSuccessful) throw IOException("HTTP $code") + if (body.contentType()?.type != "image") throw IOException("not an image") + // The cap counts bytes read, so a compressed body is not read at all. + if (isEncoded()) throw IOException("encoded image") + return newBuilder().body(body.capped(WebLinks.MAX_IMAGE_BYTES.toLong())).build() + } catch (e: IOException) { + close() + throw e + } + } + + /** + * A declared length over [limit] fails at once. Otherwise reading past it throws, so a truncated + * picture is never handed on to be decoded. + */ + private fun ResponseBody.capped(limit: Long): ResponseBody { + if (contentLength() > limit) throw IOException("image over cap") + val counting = object : ForwardingSource(source()) { + private var total = 0L + override fun read(sink: Buffer, byteCount: Long): Long { + val read = super.read(sink, byteCount) + if (read > 0) { + total += read + if (total > limit) throw IOException("image over cap") + } + return read + } + } + return counting.buffer().asResponseBody(contentType(), contentLength()) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt new file mode 100644 index 0000000000..5d6129bcc5 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt @@ -0,0 +1,19 @@ +package com.flipcash.app.messenger.internal.link + +import android.content.Context +import coil3.ImageLoader +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import okhttp3.OkHttpClient + +/** + * The loader for a preview's picture, on [client] from [webImageClient] and nothing else: it shares + * neither the app's loader nor that loader's client, which keeps cookies and follows any redirect. + * + * No disk cache. The default directory is the app loader's, and a picture of an outside page has + * no business in a store the rest of the app reads. + */ +internal fun webPreviewImageLoader(context: Context, client: OkHttpClient): ImageLoader = + ImageLoader.Builder(context) + .components { add(OkHttpNetworkFetcherFactory(callFactory = { client })) } + .diskCache(null) + .build() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt index b0cd30a6c6..086ac5f3ba 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -70,7 +70,10 @@ private fun InetAddress.nat64Embedded(): InetAddress? { * interceptors add credentials. Redirects are followed by hand so each hop is checked. It never * uses a system proxy, which would resolve the name itself and so bypass [PublicOnlyDns]. */ -internal fun webPreviewClient(dns: Dns = PublicOnlyDns()): OkHttpClient = OkHttpClient.Builder() +internal fun webPreviewClient(dns: Dns = PublicOnlyDns()): OkHttpClient = webClientBuilder(dns).build() + +/** The transport settings every outside fetch, page or image, shares. */ +internal fun webClientBuilder(dns: Dns): OkHttpClient.Builder = OkHttpClient.Builder() .dns(dns) .proxy(Proxy.NO_PROXY) .cookieJar(CookieJar.NO_COOKIES) @@ -80,7 +83,31 @@ internal fun webPreviewClient(dns: Dns = PublicOnlyDns()): OkHttpClient = OkHttp .connectTimeout(WebLinks.TIMEOUT.toJavaDuration()) .readTimeout(WebLinks.TIMEOUT.toJavaDuration()) .callTimeout((WebLinks.TIMEOUT * 2).toJavaDuration()) - .build() + +/** Port 443 only, on every hop (parity decision D12), https, and a host a preview may fetch (D13). */ +internal fun HttpUrl.isFetchable(): Boolean = scheme == "https" && port == 443 && WebLinks.isEligibleHost(host) + +/** Repeated headers that disagree are a smuggling shape; fail rather than pick one (parity decision D17). */ +internal fun Response.requireConsistentLength() { + if (headers.values("Content-Length").distinct().size > 1) throw IOException("conflicting Content-Length") +} + +/** + * Where a redirect points, or null when it is unusable: no `Location`, a backslash or escaped host + * in it (D11, D16), or one that does not resolve. Throws when repeated `Location` headers disagree. + */ +internal fun Response.redirectTarget(current: HttpUrl): HttpUrl? { + val locations = headers.values("Location").distinct() + if (locations.size > 1) throw IOException("conflicting Location") + val location = locations.firstOrNull() + if (location == null || WebLinks.isUnsafeLocation(location)) return null + return current.resolve(location) +} + +/** Every value of every repeated Content-Encoding header, each comma-separated token, must be identity (D7, D17). */ +internal fun Response.isEncoded() = headers.values("Content-Encoding") + .flatMap { it.split(',') } + .any { !it.trim().equals("identity", ignoreCase = true) } /** The client implementation of the spec's LinkMetadataSource. A server RPC replaces it. */ internal class WebLinkLookup( @@ -111,23 +138,12 @@ internal class WebLinkLookup( // The first request plus MAX_REDIRECTS redirects. A redirect answering the last one is None. repeat(WebLinks.MAX_REDIRECTS + 1) { // Port 443 only, on every hop (parity decision D12). - if (current.scheme != "https" || current.port != 443 || !WebLinks.isEligibleHost(current.host)) { - return@runCatching LinkCard.Web.State.None - } + if (!current.isFetchable()) return@runCatching LinkCard.Web.State.None client.newCall(request(current)).await().use { response -> - // Repeated headers that disagree are a smuggling shape; fail rather than pick one (parity decision D17). - if (response.headers.values("Content-Length").distinct().size > 1) { - throw IOException("conflicting Content-Length") - } + response.requireConsistentLength() when { response.isRedirect -> { - val locations = response.headers.values("Location").distinct() - if (locations.size > 1) throw IOException("conflicting Location") - val location = locations.firstOrNull() - if (location == null || WebLinks.isUnsafeLocation(location)) { - return@runCatching LinkCard.Web.State.None - } - current = current.resolve(location) ?: return@runCatching LinkCard.Web.State.None + current = response.redirectTarget(current) ?: return@runCatching LinkCard.Web.State.None } response.code >= 500 -> throw IOException("HTTP ${response.code}") !response.isSuccessful -> return@runCatching LinkCard.Web.State.None @@ -154,11 +170,6 @@ internal class WebLinkLookup( it.subtype == "html" || it.subtype == "xhtml+xml" } ?: false - /** Every value of every repeated Content-Encoding header, each comma-separated token, must be identity. */ - private fun Response.isEncoded() = headers.values("Content-Encoding") - .flatMap { it.split(',') } - .any { !it.trim().equals("identity", ignoreCase = true) } - private fun Response.capped(): ByteArray { val source = body.source() source.request(WebLinks.MAX_BODY_BYTES.toLong()) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageClientTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageClientTest.kt new file mode 100644 index 0000000000..cd14242778 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageClientTest.kt @@ -0,0 +1,355 @@ +package com.flipcash.app.messenger.internal.link + +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.Dns +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import okio.BufferedSource +import okio.Buffer +import org.junit.Test +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.SocketAddress +import java.net.URI +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The image client follows the page rules. Most cases script the answers through an application + * interceptor placed after the client's own, so it sees exactly what the client sends and nothing + * touches the network. The cases about the transport itself run against a real TLS [MockWebServer]. + */ +class WebImageClientTest { + + private val png = byteArrayOf(1, 2, 3, 4) + + private fun reply( + request: Request, + code: Int = 200, + type: String? = "image/png", + body: ByteArray = png, + headers: Map = emptyMap(), + repeated: List> = emptyList(), + responseBody: ResponseBody? = null, + ): Response = Response.Builder() + .request(request).protocol(Protocol.HTTP_1_1).code(code).message("m") + .apply { headers.forEach { (k, v) -> header(k, v) } } + .apply { repeated.forEach { (k, v) -> addHeader(k, v) } } + .body(responseBody ?: body.toResponseBody(type?.toMediaType())) + .build() + + private fun redirect(request: Request, to: String) = reply(request, 302, null, ByteArray(0), mapOf("Location" to to)) + + private class Script(val handler: (Request) -> Response) { + val seen = CopyOnWriteArrayList() + val interceptor = Interceptor { chain -> chain.request().also { seen += it }.let(handler) } + } + + private fun client(script: Script, enabled: Boolean = true): OkHttpClient = + webImageClient(enabled = { enabled }).newBuilder().addInterceptor(script.interceptor).build() + + /** The bytes the caller would decode, or the failure it would see. */ + private fun load( + client: OkHttpClient, + url: String = "https://example.com/a.png", + configure: Request.Builder.() -> Unit = {}, + ): Result = runCatching { + client.newCall(Request.Builder().url(url).apply(configure).build()).execute().use { it.body.bytes() } + } + + private fun load(script: Script, url: String = "https://example.com/a.png", enabled: Boolean = true) = + load(client(script, enabled), url) + + /** The client only requests port 443, so a test server is reached by moving the call after the checks. */ + private fun portTo(port: Int) = Interceptor { chain -> + chain.proceed(chain.request().newBuilder().url(chain.request().url.newBuilder().port(port).build()).build()) + } + + private class Tls(host: String) { + val held = HeldCertificate.Builder().addSubjectAlternativeName(host).build() + val server = MockWebServer().apply { + useHttps(HandshakeCertificates.Builder().heldCertificate(held).build().sslSocketFactory()) + } + val clientCerts = HandshakeCertificates.Builder().addTrustedCertificate(held.certificate).build() + } + + private fun tlsClient(tls: Tls, dns: Dns = Dns { listOf(InetAddress.getByName("127.0.0.1")) }) = + webImageClient(dns = dns) + .newBuilder() + .sslSocketFactory(tls.clientCerts.sslSocketFactory(), tls.clientCerts.trustManager) + .addInterceptor(portTo(tls.server.port)) + .build() + + @Test + fun `a 200 image comes back whole`() { + assertContentEquals(png, load(Script { reply(it) }).getOrThrow()) + } + + @Test + fun `requests carry the agreed headers and no credentials whatever the caller set`() { + val script = Script { reply(it) } + load(client(script)) { + header("Accept", "*/*") + header("Accept-Encoding", "gzip") + header("User-Agent", "other") + header("Cookie", "a=b") + header("Authorization", "Bearer x") + } + val request = script.seen.single() + assertEquals(WebLinks.USER_AGENT, request.header("User-Agent")) + assertEquals("image/*", request.header("Accept")) + assertEquals("identity", request.header("Accept-Encoding")) + assertNull(request.header("Cookie")) + assertNull(request.header("Authorization")) + } + + @Test + fun `the headers reach the wire`() { + val host = "img.example.com" + val tls = Tls(host) + tls.server.enqueue(MockResponse.Builder().code(200).addHeader("Content-Type", "image/png").body(Buffer().write(png)).build()) + tls.server.start() + try { + assertContentEquals(png, load(tlsClient(tls), "https://$host/a.png").getOrThrow()) + val headers = tls.server.takeRequest(5, TimeUnit.SECONDS)!!.headers + assertEquals("identity", headers["Accept-Encoding"]) + assertEquals("image/*", headers["Accept"]) + assertEquals(WebLinks.USER_AGENT, headers["User-Agent"]) + assertNull(headers["Cookie"]) + } finally { + tls.server.close() + } + } + + @Test + fun `three redirects are followed and a fourth fails, four requests at most`() { + val hops = AtomicInteger() + val follows = Script { req -> + if (hops.getAndIncrement() < 3) redirect(req, "https://example.com/${hops.get()}.png") else reply(req) + } + assertContentEquals(png, load(follows).getOrThrow()) + assertEquals(4, follows.seen.size) + + val never = Script { req -> redirect(req, "https://example.com/next.png") } + assertTrue(load(never).isFailure) + assertEquals(4, never.seen.size) + } + + @Test + fun `an unusable redirect target fails and is not requested`() { + for (target in listOf( + "http://example.com/a.png", + "https://10.0.0.1/a.png", + "https://printer.local/a.png", + "https://a.local./a.png", + "https://ex%61mple.com/a.png", + "https://example.com\\@evil.com/a.png", + "https://example.com:8443/a.png", + "https://example.com:444/a.png", + )) { + val script = Script { req -> redirect(req, target) } + assertTrue(load(script).isFailure, target) + assertEquals(1, script.seen.size, target) + } + } + + @Test + fun `a redirect without a location fails`() { + val script = Script { req -> reply(req, 302, null, ByteArray(0)) } + assertTrue(load(script).isFailure) + assertEquals(1, script.seen.size) + } + + @Test + fun `an unusable first url is never requested`() { + for (url in listOf("http://example.com/a.png", "https://example.com:8443/a.png", "https://10.0.0.1/a.png", "https://localhost/a.png", "https://intranet/a.png")) { + val script = Script { reply(it) } + assertTrue(load(script, url).isFailure, url) + assertTrue(script.seen.isEmpty(), url) + } + } + + @Test + fun `repeated locations that differ fail, equal ones are followed`() { + val differ = Script { req -> + reply(req, 302, null, ByteArray(0), repeated = listOf("Location" to "https://a.example.com/", "Location" to "https://b.example.com/")) + } + assertTrue(load(differ).isFailure) + assertEquals(1, differ.seen.size) + + val same = Script { req -> + if (req.url.host == "example.com") { + reply(req, 302, null, ByteArray(0), repeated = listOf("Location" to "https://a.example.com/x.png", "Location" to "https://a.example.com/x.png")) + } else reply(req) + } + assertContentEquals(png, load(same).getOrThrow()) + } + + @Test + fun `any non identity content encoding across repeated headers fails`() { + for (values in listOf(listOf("gzip"), listOf("identity, gzip"), listOf("identity", "gzip"), listOf(""), listOf("identity,"), listOf("br"))) { + val script = Script { reply(it, repeated = values.map { v -> "Content-Encoding" to v }) } + assertTrue(load(script).isFailure, values.toString()) + } + val identity = Script { reply(it, repeated = listOf("Content-Encoding" to "identity", "Content-Encoding" to "Identity")) } + assertContentEquals(png, load(identity).getOrThrow()) + } + + @Test + fun `a gzip reply from a real server fails`() { + val host = "img.example.com" + val tls = Tls(host) + tls.server.enqueue( + MockResponse.Builder().code(200).addHeader("Content-Type", "image/png").addHeader("Content-Encoding", "gzip") + .body(Buffer().write(png)).build() + ) + tls.server.start() + try { + assertTrue(load(tlsClient(tls), "https://$host/a.png").isFailure) + assertEquals("identity", tls.server.takeRequest(5, TimeUnit.SECONDS)!!.headers["Accept-Encoding"]) + } finally { + tls.server.close() + } + } + + @Test + fun `repeated content lengths that differ fail`() { + val script = Script { reply(it, repeated = listOf("Content-Length" to "4", "Content-Length" to "5")) } + assertTrue(load(script).isFailure) + } + + @Test + fun `a declared length over the cap fails before the body is read`() { + val big = ByteArray(WebLinks.MAX_IMAGE_BYTES + 1) + assertTrue(load(Script { reply(it, body = big) }).isFailure) + } + + @Test + fun `an undeclared length is cut off at the cap and the load fails`() { + fun unknownLength(size: Int) = object : ResponseBody() { + override fun contentType() = "image/png".toMediaType() + override fun contentLength() = -1L + override fun source(): BufferedSource = Buffer().write(ByteArray(size)) + } + val over = load(Script { reply(it, responseBody = unknownLength(WebLinks.MAX_IMAGE_BYTES + 1)) }) + assertTrue(over.isFailure) + val exact = load(Script { reply(it, responseBody = unknownLength(WebLinks.MAX_IMAGE_BYTES)) }) + assertEquals(WebLinks.MAX_IMAGE_BYTES, exact.getOrThrow().size) + } + + @Test + fun `a body exactly at the cap is accepted`() { + val body = ByteArray(WebLinks.MAX_IMAGE_BYTES) + assertEquals(WebLinks.MAX_IMAGE_BYTES, load(Script { reply(it, body = body) }).getOrThrow().size) + } + + @Test + fun `a type that is not an image fails`() { + for (type in listOf("text/html", "application/json", "application/octet-stream", null)) { + assertTrue(load(Script { reply(it, type = type) }).isFailure, type.toString()) + } + assertContentEquals(png, load(Script { reply(it, type = "image/webp") }).getOrThrow()) + } + + @Test + fun `404 and 503 fail`() { + assertTrue(load(Script { reply(it, code = 404) }).isFailure) + assertTrue(load(Script { reply(it, code = 503) }).isFailure) + assertTrue(load(Script { throw IOException("down") }).isFailure) + } + + @Test + fun `with the flag off nothing is requested`() { + val script = Script { reply(it) } + assertTrue(load(script, enabled = false).isFailure) + assertTrue(script.seen.isEmpty()) + } + + @Test + fun `the client pins its transport settings`() { + val client = webImageClient() + assertEquals(false, client.followRedirects) + assertEquals(false, client.followSslRedirects) + assertTrue(client.dns is PublicOnlyDns) + assertEquals(okhttp3.CookieJar.NO_COOKIES, client.cookieJar) + assertNull(client.cache) + assertEquals(Proxy.NO_PROXY, client.proxy) + } + + /** A system proxy would take the CONNECT and resolve the name itself, so the public-only Dns would never run. */ + @Test + fun `a system proxy is never asked and the dns still runs`() { + val proxy = MockWebServer() + proxy.start() + try { + val asked = CopyOnWriteArrayList() + val resolved = CopyOnWriteArrayList() + val selector = object : ProxySelector() { + override fun select(uri: URI?): List { + asked += uri.toString() + return listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", proxy.port))) + } + + override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) = Unit + } + val client = webImageClient(dns = Dns { host -> resolved += host; throw java.net.UnknownHostException(host) }) + .newBuilder().proxySelector(selector).build() + assertTrue(load(client).isFailure) + assertEquals(0, proxy.requestCount) + assertTrue(asked.isEmpty(), "proxy selector was consulted: $asked") + assertEquals(listOf("example.com"), resolved.toList()) + } finally { + proxy.close() + } + } + + /** + * The loop follows redirects and checks each hop; the client must not do it first. The target is + * https on an eligible host but on the test server's own port, which the loop refuses (D12) and + * OkHttp's follow-up would reach. + */ + @Test + fun `the transport does not follow a redirect by itself`() { + val host = "img.example.com" + val tls = Tls(host) + tls.server.start() + tls.server.enqueue(MockResponse.Builder().code(302).addHeader("Location", "https://$host:${tls.server.port}/next.png").build()) + tls.server.enqueue(MockResponse.Builder().code(200).addHeader("Content-Type", "image/png").body(Buffer().write(png)).build()) + try { + assertTrue(load(tlsClient(tls), "https://$host/a.png").isFailure) + assertEquals(1, tls.server.requestCount) + } finally { + tls.server.close() + } + } + + @Test + fun `a name that resolves only to private addresses fails without a request`() { + val tls = Tls("img.example.com") + tls.server.start() + try { + val client = tlsClient(tls, dns = PublicOnlyDns { listOf(InetAddress.getByName("127.0.0.1")) }) + assertTrue(load(client, "https://img.example.com/a.png").isFailure) + assertEquals(0, tls.server.requestCount) + } finally { + tls.server.close() + } + } +} From 868d41c88d63d790b6015c71b8d07c0be98ec64e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 17:31:04 -0400 Subject: [PATCH 20/39] feat(chat): render web link cards under message text --- .../core/src/main/res/values/strings.xml | 2 + .../app/messenger/internal/ChatViewModel.kt | 4 + .../internal/screens/MessengerScreen.kt | 1 + .../screens/components/MessageList.kt | 13 + .../shared/chat/models/ChatListItem.kt | 4 + .../shared/chat/models/WebLinkPreview.kt | 19 ++ .../flipcash/shared/chat/ui/LinkCardView.kt | 9 +- .../flipcash/shared/chat/ui/MessageBubble.kt | 88 +++++-- .../flipcash/shared/chat/ui/WebLinkCard.kt | 221 ++++++++++++++++ .../chat/models/SplitAroundLinkCardTest.kt | 20 ++ .../shared/chat/ui/WebLinkCardTest.kt | 241 ++++++++++++++++++ 11 files changed, 592 insertions(+), 30 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index f16b750f0a..788e6f4993 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1029,6 +1029,8 @@ Group Chat View + + Show preview · %1$s Group Unavailable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 3f0429d892..fdbba24ef3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -118,6 +118,8 @@ import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SenderIdentity import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.models.splitAroundLinkCard +import coil3.ImageLoader +import com.flipcash.app.messenger.internal.link.WebPreviewImages import com.flipcash.shared.chat.reactions.ReactionError import com.flipcash.shared.chat.reactions.ReactionPill import com.flipcash.shared.chat.reactions.ReactionStrip @@ -250,6 +252,8 @@ internal class ChatViewModel @AssistedInject constructor( private val rosterSearch: RosterSearchSource, private val featuredGroups: FeaturedGroupsStore, private val dispatchers: DispatcherProvider, + // Last and defaulted so the transcript tests, which draw no pictures, need not supply one. + @WebPreviewImages val webPreviewImageLoader: ImageLoader? = null, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index d198167322..a0fa8c1017 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -444,6 +444,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { otherReadPointer = otherReadPointer, onAction = chatActionHandler, linkCardResolution = viewModel.linkCardResolution, + webPreviewImageLoader = viewModel.webPreviewImageLoader, onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, topBarBottom = barHeight, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 231812f7e8..2099b2803c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -74,7 +74,11 @@ import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LinkCardResolution import com.flipcash.shared.chat.models.LocalChatActionHandler +import coil3.ImageLoader import com.flipcash.shared.chat.models.LocalLinkCardResolution +import com.flipcash.shared.chat.models.LocalWebLinkPreviewMode +import com.flipcash.shared.chat.models.LocalWebPreviewImageLoader +import com.flipcash.shared.chat.models.WebLinkPreviewMode import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.reactions.ReactionRefreshPlanner @@ -107,6 +111,7 @@ internal fun MessageList( otherReadPointer: MessagePointer? = null, onAction: ChatActionHandler, linkCardResolution: LinkCardResolution, + webPreviewImageLoader: ImageLoader? = null, onJumpConsumed: () -> Unit = {}, topBarBottom: Dp = 0.dp, ) { @@ -123,6 +128,14 @@ internal fun MessageList( CompositionLocalProvider( LocalChatActionHandler provides onAction, LocalLinkCardResolution provides linkCardResolution, + // A viewer outside the group has not agreed to this device contacting a site a stranger + // linked, so the card waits for a tap. + LocalWebLinkPreviewMode provides if (state.isOutsideGroup) { + WebLinkPreviewMode.TapToLoad + } else { + WebLinkPreviewMode.Automatic + }, + LocalWebPreviewImageLoader provides webPreviewImageLoader, ) { // Whether the list has settled where this visit opens it: at the unread divider, or at the // bottom when there is none. Saveable for the same reason refreshSettled is — the screen is diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 7a0548b6f6..da21b644d6 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -261,6 +261,10 @@ enum class MessagePart(val id: String) { */ fun ChatListItem.ContentBubble.splitAroundLinkCard(): List { val card = linkCard ?: return listOf(this) + // A web card draws inside the text bubble, under the text, with the link left in the text. A + // split row would carry the receipt and the reaction pills, and they would vanish whenever the + // card is empty, which is most of the time. + if (card is LinkCard.Web) return listOf(this) val text = plainText ?: return listOf(copy(linkCard = null)) if (card.start < 0 || card.end > text.length || card.start >= card.end) { return listOf(copy(linkCard = null)) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt new file mode 100644 index 0000000000..1e83cdd7c2 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt @@ -0,0 +1,19 @@ +package com.flipcash.shared.chat.models + +import androidx.compose.runtime.staticCompositionLocalOf +import coil3.ImageLoader + +/** Whether a web card asks for its page on its own or waits to be told. */ +enum class WebLinkPreviewMode { Automatic, TapToLoad } + +/** + * TapToLoad for a viewer outside the group, who has not agreed to this device contacting a site a + * stranger linked; Automatic otherwise. Never stored, so nothing kept says who looked. + */ +val LocalWebLinkPreviewMode = staticCompositionLocalOf { WebLinkPreviewMode.Automatic } + +/** + * The loader for a preview's picture: no cookies, public-only DNS, the page rules on every hop. + * Null draws no picture, because the app's own loader keeps cookies and follows any redirect. + */ +val LocalWebPreviewImageLoader = staticCompositionLocalOf { null } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 85f1f11cba..68b51a1976 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -201,15 +201,18 @@ internal fun LinkCardView( * answer. */ @Composable -private fun rememberResolvedCard(card: LinkCard): LinkCard { +internal fun rememberResolvedCard(card: LinkCard, fetch: Boolean = true): LinkCard { val resolution = LocalLinkCardResolution.current val revision by resolution.revision.collectAsState() var live by remember(card) { mutableStateOf(resolution.peek(card) ?: card) } // Keyed on the card, so a row recomposed onto a different message drops the previous link's // query instead of finishing it into the new card. - LaunchedEffect(card, revision) { - live = resolution.resolve(card) + // + // [fetch] false still peeks, since a held answer costs no request, but asks nothing: a web card + // outside the group waits for a tap before this device contacts the site. + LaunchedEffect(card, revision, fetch) { + if (fetch) live = resolution.resolve(card) } return live diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index ae6f424896..036b68ec2c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -160,6 +160,8 @@ fun ContentBubble( } val isEdited = item.isEdited && item.isLastRow val card = item.linkCard?.takeIf { item.part == MessagePart.Card } + // A web card is not split out: it draws in the text bubble under the text. + val webCard = item.linkCard as? LinkCard.Web Row( modifier = Modifier.fillMaxWidth(), @@ -196,6 +198,10 @@ fun ContentBubble( maxWidth = bubbleMaxWidth, isEdited = isEdited, jumbo = jumbo, + webCard = webCard, + onCardLongClick = onLongClick?.takeIf { interactive }, + onCardDoubleClick = onDoubleClick?.takeIf { interactive }, + interactive = interactive, attention = attention, ) @@ -273,6 +279,10 @@ fun ContentBubble( onQuoteLongClick = onLongClick?.takeIf { interactive }, onQuoteDoubleClick = onDoubleClick?.takeIf { interactive }, jumbo = jumbo, + webCard = webCard, + onCardLongClick = onLongClick?.takeIf { interactive }, + onCardDoubleClick = onDoubleClick?.takeIf { interactive }, + interactive = interactive, attention = attention, ) @@ -415,6 +425,10 @@ private fun TextBubble( onQuoteLongClick: (() -> Unit)? = null, onQuoteDoubleClick: (() -> Unit)? = null, jumbo: Boolean = false, + webCard: LinkCard.Web? = null, + onCardLongClick: (() -> Unit)? = null, + onCardDoubleClick: (() -> Unit)? = null, + interactive: Boolean = true, attention: () -> Float = { 0f }, ) { if (jumbo) { @@ -436,11 +450,17 @@ private fun TextBubble( // quote. A bubble with no quote never widens, because the two are equal there. val surround = if (quote != null) BubbleDefaults.surroundInset else BubbleDefaults.paddingHorizontal val bodyInset = BubbleDefaults.paddingHorizontal - surround + // Asked here rather than in the card so the bubble can widen for a card that draws: the image + // would otherwise be squeezed to the text's width. A chip, a card still loading and an empty + // answer leave the bubble text-sized. + val web = webCard?.let { rememberWebLinkCard(it) } + val cardDrawn = web?.resolved != null Bubble( isFromSelf, position, maxWidth, modifier, + minWidth = if (cardDrawn) maxWidth else 0.dp, horizontalPadding = surround, attention = attention, ) { @@ -558,35 +578,49 @@ private fun TextBubble( ) } - if (quote == null) { - bodyText() - } else { - QuotedBody( - gap = BubbleDefaults.surroundInset, - quote = { - ChatQuotePanel( - quote = quote, - onClick = onQuoteClick, - onLongClick = onQuoteLongClick, - onDoubleClick = onQuoteDoubleClick, - // Tagged because the citation repeats the quoted message's own text, so a - // UI test matching on that text cannot tell the two apart. - modifier = Modifier.testTag(REPLY_QUOTE_TAG), + Column { + // The marker is pinned to the text's corner, so the card below does not sit under it. + Box(modifier = Modifier.addIf(cardDrawn) { Modifier.fillMaxWidth() }) { + if (quote == null) { + bodyText() + } else { + QuotedBody( + gap = BubbleDefaults.surroundInset, + quote = { + ChatQuotePanel( + quote = quote, + onClick = onQuoteClick, + onLongClick = onQuoteLongClick, + onDoubleClick = onQuoteDoubleClick, + // Tagged because the citation repeats the quoted message's own text, so a + // UI test matching on that text cannot tell the two apart. + modifier = Modifier.testTag(REPLY_QUOTE_TAG), + ) + }, + body = bodyText, ) - }, - body = bodyText, - ) - } + } - if (isEdited) { - Text( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = bodyInset), - text = markerLabel, - style = markerStyle, - color = CodeTheme.colors.textSecondary, - ) + if (isEdited) { + Text( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = bodyInset), + text = markerLabel, + style = markerStyle, + color = CodeTheme.colors.textSecondary, + ) + } + } + if (web != null) { + WebLinkCard( + state = web, + modifier = Modifier.padding(horizontal = bodyInset), + onLongClick = onCardLongClick, + onDoubleClick = onCardDoubleClick, + interactive = interactive, + ) + } } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt new file mode 100644 index 0000000000..1e058b656d --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt @@ -0,0 +1,221 @@ +package com.flipcash.shared.chat.ui + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import coil3.compose.AsyncImage +import com.flipcash.core.R +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LocalWebLinkPreviewMode +import com.flipcash.shared.chat.models.LocalWebPreviewImageLoader +import com.flipcash.shared.chat.models.WebLinkPreviewMode +import com.getcode.theme.CodeTheme + +internal const val WEB_LINK_CARD_TAG = "web_link_card" +internal const val WEB_LINK_CHIP_TAG = "web_link_chip" +internal const val WEB_LINK_IMAGE_TAG = "web_link_image" + +/** Width over height of the picture, the shape Open Graph images are sized for. */ +private const val IMAGE_ASPECT = 1.91f +private const val TITLE_LINES = 2 +private const val DESCRIPTION_LINES = 2 + +/** + * What a web card draws now: [resolved] once the page has answered, [chipHost] while the viewer is + * outside the group and has not asked, and neither otherwise. A card still loading in automatic + * mode, an empty answer, and a fetch that failed after the chip was tapped all draw nothing. + */ +@Stable +internal class WebLinkCardState( + val url: String, + val resolved: LinkCard.Web.State.Resolved?, + val chipHost: String?, + val ask: () -> Unit, +) + +/** + * The state of [card] for the viewer: asks for its page on its own, or only after the chip is + * tapped when the viewer is outside the group (see [LocalWebLinkPreviewMode]). + * + * The tap is remembered across scrolling, so a message drawn again after a failed fetch asks again + * with no chip. + */ +@Composable +internal fun rememberWebLinkCard(card: LinkCard.Web): WebLinkCardState { + val mode = LocalWebLinkPreviewMode.current + var asked by rememberSaveable(card.url) { mutableStateOf(false) } + val live = rememberResolvedCard( + card = card, + fetch = mode == WebLinkPreviewMode.Automatic || asked, + ) as? LinkCard.Web ?: card + val showChip = mode == WebLinkPreviewMode.TapToLoad && !asked && + live.state == LinkCard.Web.State.Loading + return WebLinkCardState( + url = card.url, + resolved = live.state as? LinkCard.Web.State.Resolved, + chipHost = if (showChip) chipHostOf(card.url) else null, + ask = { asked = true }, + ) +} + +/** The host a chip names: lower case, without a leading "www.". */ +private fun chipHostOf(url: String): String = + Uri.parse(url).host.orEmpty().lowercase().removePrefix("www.") + +/** + * The card under a message's text, or the chip that asks for it. The whole card is one target that + * opens the page through [LocalUriHandler], so the host's "leaving Flipcash" warning applies as it + * does to the link text; a long press goes to the message's own. + */ +@Composable +internal fun WebLinkCard( + state: WebLinkCardState, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + interactive: Boolean = true, +) { + val resolved = state.resolved + val chipHost = state.chipHost + when { + resolved != null -> WebPreview( + preview = resolved, + url = state.url, + modifier = modifier, + interactive = interactive, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + ) + + chipHost != null -> ShowPreviewChip(host = chipHost, onClick = state.ask, modifier = modifier) + } +} + +@Composable +private fun WebPreview( + preview: LinkCard.Web.State.Resolved, + url: String, + interactive: Boolean, + onLongClick: (() -> Unit)?, + onDoubleClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + // The link that was sent, never one the page named. + val uriHandler = LocalUriHandler.current + val shape = RoundedCornerShape(CodeTheme.dimens.staticGrid.x2) + Column( + modifier = modifier + .padding(top = CodeTheme.dimens.staticGrid.x2) + .fillMaxWidth() + .clip(shape) + .testTag(WEB_LINK_CARD_TAG) + .combinedClickable( + enabled = interactive, + onClick = { uriHandler.openUri(url) }, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + // The row's select plays the tick; a second, platform haptic doubles it. + hapticFeedbackEnabled = false, + ), + ) { + PreviewImage(preview.imageUrl) + Column( + modifier = Modifier.padding(top = CodeTheme.dimens.staticGrid.x1), + ) { + Text( + text = preview.host, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = preview.title, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = TITLE_LINES, + overflow = TextOverflow.Ellipsis, + ) + preview.description?.let { description -> + Text( + text = description, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = DESCRIPTION_LINES, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** + * The page's picture, through the preview loader only. With no loader, no picture: the app's own + * loader keeps cookies and follows any redirect. A picture that fails to load takes its slot with + * it, so the card closes up around the text rather than leaving a hole. + */ +@Composable +private fun PreviewImage(imageUrl: String?) { + val loader = LocalWebPreviewImageLoader.current ?: return + // The loader handles file and content addresses too, and the address came from a page. + if (imageUrl == null || !imageUrl.startsWith("https://")) return + var failed by remember(imageUrl) { mutableStateOf(false) } + if (failed) return + AsyncImage( + model = imageUrl, + imageLoader = loader, + contentDescription = null, + contentScale = ContentScale.Crop, + onError = { failed = true }, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(IMAGE_ASPECT) + .clip(RoundedCornerShape(CodeTheme.dimens.staticGrid.x1)) + .background(CodeTheme.colors.textSecondary.copy(alpha = 0.15f)) + .testTag(WEB_LINK_IMAGE_TAG), + ) +} + +// Chip copy is pending UX review, with the iOS string. +@Composable +private fun ShowPreviewChip(host: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(percent = 50) + Text( + text = stringResource(R.string.action_webLinkCard_showPreview, host), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier + .padding(top = CodeTheme.dimens.staticGrid.x2) + .clip(shape) + .border(CodeTheme.dimens.border, CodeTheme.colors.textSecondary.copy(alpha = 0.4f), shape) + .combinedClickable(onClick = onClick, hapticFeedbackEnabled = false) + .padding( + horizontal = CodeTheme.dimens.staticGrid.x2, + vertical = CodeTheme.dimens.staticGrid.x1, + ) + .testTag(WEB_LINK_CHIP_TAG), + ) +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt index 21c5f07e75..6c2256dc9b 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt @@ -190,4 +190,24 @@ class SplitAroundLinkCardTest { assertEquals(listOf(DetectedMention(start = 3, end = 8, username = "jeff")), rows[0].mentions) assertEquals(emptyList(), rows[1].mentions) } + + @Test + fun `a web card stays inside one bubble with the link left in the text`() { + val text = "read https://example.com/a now" + val card = LinkCard.Web(url = "https://example.com/a", start = 5, end = 26) + val rows = ChatListItem.ContentBubble( + messageId = 7, + contentIndex = 0, + content = MessageContent.Text(text), + isFromSelf = true, + timestamp = Instant.fromEpochSeconds(1_000), + isEdited = false, + linkCard = card, + ).splitAroundLinkCard() + + val row = rows.single() + assertNull(row.part) + assertNull(row.partText) + assertEquals(card, row.linkCard) + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt new file mode 100644 index 0000000000..b6d44f8ecc --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt @@ -0,0 +1,241 @@ +package com.flipcash.shared.chat.ui + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import coil3.ImageLoader +import coil3.annotation.ExperimentalCoilApi +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.request.Options +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCardResolution +import com.flipcash.shared.chat.models.LocalLinkCardResolution +import com.flipcash.shared.chat.models.LocalWebLinkPreviewMode +import com.flipcash.shared.chat.models.LocalWebPreviewImageLoader +import com.flipcash.shared.chat.models.WebLinkPreviewMode +import com.getcode.theme.DesignSystem +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.IOException +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals + +/** + * What a web card draws and when it asks. Loading and an empty answer draw nothing, a viewer outside + * the group asks only when told to, and the whole card opens the page through the host's handler. + */ +@RunWith(RobolectricTestRunner::class) +class WebLinkCardTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val url = "https://www.example.com/a" + private val resolved = LinkCard.Web.State.Resolved( + title = "The title", + description = "A description", + imageUrl = null, + host = "example.com", + ) + + private class FakeResolution(private val answer: (LinkCard) -> LinkCard) : LinkCardResolution { + val calls = AtomicInteger() + val peeked: MutableMap = mutableMapOf() + override val revision: StateFlow = MutableStateFlow(0) + override fun peek(card: LinkCard): LinkCard? = peeked[(card as LinkCard.Web).url] + override suspend fun resolve(card: LinkCard): LinkCard { + calls.incrementAndGet() + return answer(card) + } + } + + private fun card(state: LinkCard.Web.State = LinkCard.Web.State.Loading) = + LinkCard.Web(url = url, start = 0, end = url.length, state = state) + + private fun resolving(state: LinkCard.Web.State) = + FakeResolution { (it as LinkCard.Web).copy(state = state) } + + private fun setCard( + resolution: FakeResolution, + mode: WebLinkPreviewMode = WebLinkPreviewMode.Automatic, + uriHandler: UriHandler = RecordingUriHandler(), + imageLoader: ImageLoader? = null, + onLongClick: (() -> Unit)? = null, + ) { + composeTestRule.setContent { + DesignSystem { + CompositionLocalProvider( + LocalLinkCardResolution provides resolution, + LocalWebLinkPreviewMode provides mode, + LocalWebPreviewImageLoader provides imageLoader, + LocalUriHandler provides uriHandler, + ) { + Card(onLongClick) + } + } + } + } + + @Composable + private fun Card(onLongClick: (() -> Unit)?) { + val state = rememberWebLinkCard(card()) + WebLinkCard(state = state, onLongClick = onLongClick, onDoubleClick = null) + } + + private class RecordingUriHandler : UriHandler { + val opened = mutableListOf() + override fun openUri(uri: String) { + opened += uri + } + } + + @Test + fun `a resolved card draws its title description and host`() { + setCard(resolving(resolved)) + + composeTestRule.onNodeWithText("The title").assertIsDisplayed() + composeTestRule.onNodeWithText("A description").assertIsDisplayed() + composeTestRule.onNodeWithText("example.com").assertIsDisplayed() + composeTestRule.onAllNodesWithText("og:site_name").assertCountEquals(0) + } + + @Test + fun `an empty answer draws nothing`() { + setCard(resolving(LinkCard.Web.State.None)) + + composeTestRule.waitForIdle() + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) + composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) + } + + @Test + fun `a card still loading in automatic mode draws nothing`() { + // The lookup never answers in this run: the card stays in its loading state. + setCard(FakeResolution { it }) + + composeTestRule.waitForIdle() + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) + composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) + } + + @Test + fun `outside the group nothing is asked until the chip is tapped and then it is asked once`() { + val resolution = resolving(resolved) + setCard(resolution, mode = WebLinkPreviewMode.TapToLoad) + + composeTestRule.waitForIdle() + assertEquals(0, resolution.calls.get()) + // The host reads without its www. + composeTestRule.onNodeWithText("Show preview · example.com").assertIsDisplayed() + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) + + composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).performClick() + composeTestRule.waitForIdle() + + assertEquals(1, resolution.calls.get()) + composeTestRule.onNodeWithText("The title").assertIsDisplayed() + composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) + } + + @Test + fun `a fetch that fails after the chip is tapped shows nothing and no chip`() { + val resolution = FakeResolution { it } + setCard(resolution, mode = WebLinkPreviewMode.TapToLoad) + + composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).performClick() + composeTestRule.waitForIdle() + + assertEquals(1, resolution.calls.get()) + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) + composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) + } + + @Test + fun `an answer already held is drawn outside the group without a tap`() { + val resolution = FakeResolution { it } + resolution.peeked[url] = card(resolved) + setCard(resolution, mode = WebLinkPreviewMode.TapToLoad) + + composeTestRule.waitForIdle() + assertEquals(0, resolution.calls.get()) + composeTestRule.onNodeWithText("The title").assertIsDisplayed() + } + + @Test + fun `a click on a resolved card opens the card url`() { + val handler = RecordingUriHandler() + setCard(resolving(resolved), uriHandler = handler) + + composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG).performClick() + + assertEquals(listOf(url), handler.opened) + } + + @Test + fun `an image that fails to load drops its slot and keeps the text`() { + val loader = ImageLoader.Builder(androidx.test.core.app.ApplicationProvider.getApplicationContext()) + .components { add(FailingFetcherFactory) } + .build() + setCard( + resolving(resolved.copy(imageUrl = "https://img.example.com/p.png")), + imageLoader = loader, + ) + + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.onAllNodesWithTag(WEB_LINK_IMAGE_TAG, useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && + composeTestRule.onAllNodesWithText("The title").fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText("The title").assertIsDisplayed() + composeTestRule.onNodeWithText("example.com").assertIsDisplayed() + } + + @Test + fun `a picture still loading keeps its slot`() { + val loader = ImageLoader.Builder(androidx.test.core.app.ApplicationProvider.getApplicationContext()) + .components { add(HangingFetcherFactory) } + .build() + setCard( + resolving(resolved.copy(imageUrl = "https://img.example.com/p.png")), + imageLoader = loader, + ) + + composeTestRule.onNodeWithText("The title").assertIsDisplayed() + composeTestRule.onAllNodesWithTag(WEB_LINK_IMAGE_TAG, useUnmergedTree = true).assertCountEquals(1) + } + + @Test + fun `no picture is asked for without the preview loader`() { + setCard(resolving(resolved.copy(imageUrl = "https://img.example.com/p.png"))) + + composeTestRule.onNodeWithText("The title").assertIsDisplayed() + composeTestRule.onAllNodesWithTag(WEB_LINK_IMAGE_TAG, useUnmergedTree = true).assertCountEquals(0) + } + + @OptIn(ExperimentalCoilApi::class) + private object HangingFetcherFactory : Fetcher.Factory { + override fun create(data: coil3.Uri, options: Options, imageLoader: ImageLoader): Fetcher = + Fetcher { awaitCancellation() } + } + + @OptIn(ExperimentalCoilApi::class) + private object FailingFetcherFactory : Fetcher.Factory { + override fun create(data: coil3.Uri, options: Options, imageLoader: ImageLoader): Fetcher = + Fetcher { throw IOException("no image") } + } +} From 12c62826ed45155bb3b0d774796021180b60cedc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 17:35:29 -0400 Subject: [PATCH 21/39] feat(chat): show no web card while web link previews are off --- .../app/messenger/internal/ChatViewModel.kt | 10 ++++ .../internal/link/LinkCardMemoryModule.kt | 2 + .../internal/link/MessageLinkPrefetcher.kt | 7 ++- .../internal/screens/MessengerScreen.kt | 2 + .../screens/components/MessageList.kt | 9 ++-- .../link/MessageLinkPrefetcherTest.kt | 27 +++++++++++ .../shared/chat/models/WebLinkPreview.kt | 7 ++- .../flipcash/shared/chat/ui/WebLinkCard.kt | 5 +- .../shared/chat/ui/WebLinkCardTest.kt | 48 +++++++++++++++++++ 9 files changed, 107 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index fdbba24ef3..31440031ae 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -17,6 +17,9 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.flatMap import androidx.paging.map +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.featureflags.NoOpFeatureFlagController import com.flipcash.analytics.CashLinkChoice import com.flipcash.analytics.GroupAccess as AnalyticsGroupAccess import com.flipcash.analytics.GroupGateFunding @@ -254,12 +257,19 @@ internal class ChatViewModel @AssistedInject constructor( private val dispatchers: DispatcherProvider, // Last and defaulted so the transcript tests, which draw no pictures, need not supply one. @WebPreviewImages val webPreviewImageLoader: ImageLoader? = null, + featureFlags: FeatureFlagController = NoOpFeatureFlagController, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { + /** + * Whether web link previews are on. Observed rather than read once, because the flag can flip + * in the staff menu while a chat is open. Off means no web card is drawn at all. + */ + val webLinkPreviewsEnabled: StateFlow = featureFlags.observe(FeatureFlag.WebLinkPreviews) + /** * A photo in the composer: its id in [ChatMediaUploads] and where it came from, for the * thumbnail. A camera shot staged at the shutter carries the frame taken then as [preview], diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt index 78f0b6949d..f1f929a7b8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt @@ -95,6 +95,7 @@ internal abstract class LinkCardMemoryModule { group: GroupLinkLookup, user: UserLinkLookup, web: WebLinkLookup, + flags: FeatureFlagController, dispatchers: DispatcherProvider, ): MessageLinkPrefetcher = MessageLinkPrefetcher( classifier = classifier, @@ -103,6 +104,7 @@ internal abstract class LinkCardMemoryModule { user = { user(it) }, web = { web(it) }, dispatchers = dispatchers, + webEnabled = { flags.get(FeatureFlag.WebLinkPreviews) }, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt index bde3a23fc2..82c16f7283 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt @@ -22,7 +22,7 @@ import kotlin.time.Duration.Companion.milliseconds * drawn resolved on the frame its message first appears. See [MessageLinkPrefetch]. * * Group and person links are always fetched ahead; outside pages only when the caller passes - * `webLinks`. Cash and token cards keep resolving when drawn, as before. + * `webLinks` and web link previews are on. Cash and token cards keep resolving when drawn, as before. * * Links are found exactly as the transcript finds them -- the same text, the same detection pass, * the same classifier -- so a link prefetched here is the link a card is drawn for. @@ -34,6 +34,7 @@ internal class MessageLinkPrefetcher( private val user: suspend (identity: LinkCard.User.Identity) -> Result, private val web: suspend (url: String) -> Result, dispatchers: DispatcherProvider, + private val webEnabled: suspend () -> Boolean = { true }, ) : MessageLinkPrefetch { private val scope = CoroutineScope(SupervisorJob() + dispatchers.IO) @@ -46,12 +47,14 @@ internal class MessageLinkPrefetcher( // store may still be loading; wait briefly for it rather than asking for everything. withTimeoutOrNull(LOAD_WAIT) { memory.awaitLoaded() } + // Read once per batch: the flag can flip in the staff menu while a chat is open. + val webAllowed = webLinks && webEnabled() val lookups = messages .asSequence() .filterNot { it.redacted } .flatMap { it.content.asSequence() } .mapNotNull { content -> content.linkableText()?.let { classifier.firstCard(detectUrls(it)) } } - .mapNotNull { card -> lookup(card, webLinks) } + .mapNotNull { card -> lookup(card, webAllowed) } .toList() if (lookups.isEmpty() || wait <= Duration.ZERO) return diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index a0fa8c1017..ab6fdd7518 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -74,6 +74,7 @@ import com.flipcash.shared.common.ui.profile.PrefetchProfileCover @Composable internal fun MessengerScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val webLinkPreviewsEnabled by viewModel.webLinkPreviewsEnabled.collectAsStateWithLifecycle() val messages = viewModel.messages.collectAsLazyPagingItems() val mediaProgress = viewModel.mediaSendProgress.collectAsStateWithLifecycle() val mediaProgressOf = remember(mediaProgress) { { mediaProgress.value } } @@ -445,6 +446,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { onAction = chatActionHandler, linkCardResolution = viewModel.linkCardResolution, webPreviewImageLoader = viewModel.webPreviewImageLoader, + webLinkPreviewsEnabled = webLinkPreviewsEnabled, onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, topBarBottom = barHeight, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 2099b2803c..7285a4f812 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -112,6 +112,7 @@ internal fun MessageList( onAction: ChatActionHandler, linkCardResolution: LinkCardResolution, webPreviewImageLoader: ImageLoader? = null, + webLinkPreviewsEnabled: Boolean = true, onJumpConsumed: () -> Unit = {}, topBarBottom: Dp = 0.dp, ) { @@ -130,10 +131,10 @@ internal fun MessageList( LocalLinkCardResolution provides linkCardResolution, // A viewer outside the group has not agreed to this device contacting a site a stranger // linked, so the card waits for a tap. - LocalWebLinkPreviewMode provides if (state.isOutsideGroup) { - WebLinkPreviewMode.TapToLoad - } else { - WebLinkPreviewMode.Automatic + LocalWebLinkPreviewMode provides when { + !webLinkPreviewsEnabled -> WebLinkPreviewMode.Off + state.isOutsideGroup -> WebLinkPreviewMode.TapToLoad + else -> WebLinkPreviewMode.Automatic }, LocalWebPreviewImageLoader provides webPreviewImageLoader, ) { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt index f874f05840..26d21d8fa3 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt @@ -67,6 +67,7 @@ class MessageLinkPrefetcherTest { private fun TestScope.prefetcher( memory: LinkCardMemory, web: suspend (String) -> Result = { Result.failure(IOException("not used")) }, + webEnabled: suspend () -> Boolean = { true }, group: suspend (ChatId) -> Result = { Result.failure(IOException("not used")) }, ) = MessageLinkPrefetcher( classifier = LinkCardClassifier(router), @@ -75,6 +76,7 @@ class MessageLinkPrefetcherTest { user = { Result.failure(IOException("not used")) }, web = web, dispatchers = TestDispatcherProvider(StandardTestDispatcher(testScheduler)), + webEnabled = webEnabled, ) @Test @@ -169,6 +171,31 @@ class MessageLinkPrefetcherTest { assertEquals(page, memory.webs[WebLinks.cacheKey(pageUrl)]) } + @Test + fun `a web link is not fetched while web link previews are off, and is again once on`() = runTest { + val memory = LinkCardMemory() + var asked = 0 + var on = false + val prefetcher = prefetcher(memory, web = { asked++; Result.success(page) }, webEnabled = { on }) + + prefetcher.prefetch(listOf(message(pageText)), wait = 1.seconds, webLinks = true) + assertEquals(0, asked) + assertTrue(memory.webs.isEmpty()) + + on = true + prefetcher.prefetch(listOf(message(pageText, id = 2)), wait = 1.seconds, webLinks = true) + assertEquals(1, asked) + } + + @Test + fun `a group invite is prefetched with web link previews off`() = runTest { + val memory = LinkCardMemory() + prefetcher(memory, group = { Result.success(resolved) }, webEnabled = { false }) + .prefetch(listOf(message(inviteText)), wait = 1.seconds, webLinks = true) + + assertEquals(resolved, memory.groups[chatId]) + } + @Test fun `a group invite is prefetched whether or not web links are allowed`() = runTest { val memory = LinkCardMemory() diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt index 1e83cdd7c2..2398828a6b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt @@ -3,8 +3,11 @@ package com.flipcash.shared.chat.models import androidx.compose.runtime.staticCompositionLocalOf import coil3.ImageLoader -/** Whether a web card asks for its page on its own or waits to be told. */ -enum class WebLinkPreviewMode { Automatic, TapToLoad } +/** + * Whether a web card asks for its page on its own, waits to be told, or is not drawn at all + * because web link previews are switched off. + */ +enum class WebLinkPreviewMode { Automatic, TapToLoad, Off } /** * TapToLoad for a viewer outside the group, who has not agreed to this device contacting a site a diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt index 1e058b656d..984221aea2 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt @@ -67,13 +67,14 @@ internal fun rememberWebLinkCard(card: LinkCard.Web): WebLinkCardState { var asked by rememberSaveable(card.url) { mutableStateOf(false) } val live = rememberResolvedCard( card = card, - fetch = mode == WebLinkPreviewMode.Automatic || asked, + fetch = mode == WebLinkPreviewMode.Automatic || (mode == WebLinkPreviewMode.TapToLoad && asked), ) as? LinkCard.Web ?: card val showChip = mode == WebLinkPreviewMode.TapToLoad && !asked && live.state == LinkCard.Web.State.Loading return WebLinkCardState( url = card.url, - resolved = live.state as? LinkCard.Web.State.Resolved, + // Off draws no card at all, even from an answer held from before the flag was switched off. + resolved = (live.state as? LinkCard.Web.State.Resolved)?.takeIf { mode != WebLinkPreviewMode.Off }, chipHost = if (showChip) chipHostOf(card.url) else null, ask = { asked = true }, ) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt index b6d44f8ecc..4f4da2c502 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt @@ -3,6 +3,7 @@ package com.flipcash.shared.chat.ui import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.test.assertCountEquals @@ -177,6 +178,53 @@ class WebLinkCardTest { composeTestRule.onNodeWithText("The title").assertIsDisplayed() } + @Test + fun `with previews off a non-member sees no chip and nothing is asked`() { + val resolution = resolving(resolved) + setCard(resolution, mode = WebLinkPreviewMode.Off) + + composeTestRule.waitForIdle() + assertEquals(0, resolution.calls.get()) + composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) + } + + @Test + fun `with previews off no card is drawn in place of automatic one nor from a held answer`() { + val resolution = resolving(resolved) + resolution.peeked[url] = card(resolved) + setCard(resolution, mode = WebLinkPreviewMode.Off) + + composeTestRule.waitForIdle() + assertEquals(0, resolution.calls.get()) + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) + composeTestRule.onAllNodesWithText("The title").assertCountEquals(0) + } + + @Test + fun `flipping previews on while the message is drawn brings the card back`() { + val resolution = resolving(resolved) + val mode = mutableStateOf(WebLinkPreviewMode.Off) + composeTestRule.setContent { + DesignSystem { + CompositionLocalProvider( + LocalLinkCardResolution provides resolution, + LocalWebLinkPreviewMode provides mode.value, + LocalUriHandler provides RecordingUriHandler(), + ) { + Card(onLongClick = null) + } + } + } + composeTestRule.waitForIdle() + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) + + mode.value = WebLinkPreviewMode.Automatic + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("The title").assertIsDisplayed() + } + @Test fun `a click on a resolved card opens the card url`() { val handler = RecordingUriHandler() From 0f98b2ed69b22762e4b100f9103405d0b0cbc4a4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 17:39:03 -0400 Subject: [PATCH 22/39] fix(messenger): drop a page image the fetch rules refuse --- .../messenger/internal/link/WebPageParser.kt | 4 ++- .../internal/link/WebPageParserTest.kt | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPageParser.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPageParser.kt index 72ecfaf784..74926e2b58 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPageParser.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPageParser.kt @@ -41,9 +41,11 @@ internal object WebPageParser { val title = metas["og:title"] ?: titleText ?: return null val description = metas["og:description"] ?: metas["description"] val base = finalUrl.toHttpUrlOrNull() + // An address the image fetch would refuse draws no slot, so it is dropped here (D11, D12, D13). val image = metas["og:image"] + ?.takeUnless { WebLinks.hasEscapedHost(it) || WebLinks.isUnsafeLocation(it) } ?.let { base?.resolve(it) } - ?.takeIf { it.scheme == "https" && WebLinks.isEligibleHost(it.host) } + ?.takeIf { it.scheme == "https" && it.port == 443 && WebLinks.isEligibleHost(it.host) } ?.toString() val host = (base?.host ?: return null).lowercase().removePrefix("www.") return LinkCard.Web.State.Resolved(title, description, image, host) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebPageParserTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebPageParserTest.kt index ba9e5ce57b..cfb61be9ce 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebPageParserTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebPageParserTest.kt @@ -51,4 +51,39 @@ class WebPageParserTest { } if (failures.isNotEmpty()) fail("${failures.size} of ${rows.length()} vectors failed:\n" + failures.joinToString("\n")) } + + private fun imageOf(ogImage: String, finalUrl: String = "https://example.com/post"): String? = + WebPageParser.parse( + """T""".toByteArray(), + finalUrl, + )?.imageUrl + + @Test + fun `a good https image is kept`() { + assertEquals("https://img.example.com/p.png", imageOf("https://img.example.com/p.png")) + assertEquals("https://example.com/p.png", imageOf("/p.png")) + assertEquals("https://img.example.com/p.png", imageOf("https://img.example.com:443/p.png")) + } + + @Test + fun `an image that is not https is dropped`() { + assertEquals(null, imageOf("http://img.example.com/p.png")) + } + + @Test + fun `an image on another port is dropped`() { + assertEquals(null, imageOf("https://img.example.com:8443/p.png")) + } + + @Test + fun `an image with an escaped host is dropped`() { + assertEquals(null, imageOf("https://im%67.example.com/p.png")) + assertEquals(null, imageOf("//im%67.example.com/p.png")) + } + + @Test + fun `an image on an ineligible host is dropped`() { + assertEquals(null, imageOf("https://127.0.0.1/p.png")) + assertEquals(null, imageOf("https://localhost/p.png")) + } } From d54a2a57e9f9461c57e40b1212ace33d4941c0d1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 17:41:49 -0400 Subject: [PATCH 23/39] feat(chat): draw the web card as a panel with a filled chip --- .../flipcash/shared/chat/ui/WebLinkCard.kt | 11 +++++--- .../shared/chat/ui/WebLinkCardTest.kt | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt index 984221aea2..4519e6c987 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt @@ -2,7 +2,6 @@ package com.flipcash.shared.chat.ui import android.net.Uri import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio @@ -19,6 +18,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag @@ -38,6 +38,9 @@ internal const val WEB_LINK_IMAGE_TAG = "web_link_image" /** Width over height of the picture, the shape Open Graph images are sized for. */ private const val IMAGE_ASPECT = 1.91f +/** The app is dark only, so the panel and the chip are white at a low alpha over the bubble. */ +private val PANEL_FILL = Color.White.copy(alpha = 0.08f) +private val CHIP_FILL = Color.White.copy(alpha = 0.12f) private const val TITLE_LINES = 2 private const val DESCRIPTION_LINES = 2 @@ -130,6 +133,7 @@ private fun WebPreview( .padding(top = CodeTheme.dimens.staticGrid.x2) .fillMaxWidth() .clip(shape) + .background(PANEL_FILL) .testTag(WEB_LINK_CARD_TAG) .combinedClickable( enabled = interactive, @@ -142,7 +146,7 @@ private fun WebPreview( ) { PreviewImage(preview.imageUrl) Column( - modifier = Modifier.padding(top = CodeTheme.dimens.staticGrid.x1), + modifier = Modifier.padding(CodeTheme.dimens.staticGrid.x2), ) { Text( text = preview.host, @@ -192,7 +196,6 @@ private fun PreviewImage(imageUrl: String?) { modifier = Modifier .fillMaxWidth() .aspectRatio(IMAGE_ASPECT) - .clip(RoundedCornerShape(CodeTheme.dimens.staticGrid.x1)) .background(CodeTheme.colors.textSecondary.copy(alpha = 0.15f)) .testTag(WEB_LINK_IMAGE_TAG), ) @@ -211,7 +214,7 @@ private fun ShowPreviewChip(host: String, onClick: () -> Unit, modifier: Modifie modifier = modifier .padding(top = CodeTheme.dimens.staticGrid.x2) .clip(shape) - .border(CodeTheme.dimens.border, CodeTheme.colors.textSecondary.copy(alpha = 0.4f), shape) + .background(CHIP_FILL) .combinedClickable(onClick = onClick, hapticFeedbackEnabled = false) .padding( horizontal = CodeTheme.dimens.staticGrid.x2, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt index 4f4da2c502..c32ea1f92a 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt @@ -13,7 +13,9 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp import coil3.ImageLoader import coil3.annotation.ExperimentalCoilApi import coil3.fetch.FetchResult @@ -253,6 +255,29 @@ class WebLinkCardTest { composeTestRule.onNodeWithText("example.com").assertIsDisplayed() } + @Test + fun `the text sits inside the panel by ten dp and the picture meets its top and sides`() { + val loader = ImageLoader.Builder(androidx.test.core.app.ApplicationProvider.getApplicationContext()) + .components { add(HangingFetcherFactory) } + .build() + setCard( + resolving(resolved.copy(imageUrl = "https://img.example.com/p.png")), + imageLoader = loader, + ) + + val panel = composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG).getUnclippedBoundsInRoot() + val image = composeTestRule.onNodeWithTag(WEB_LINK_IMAGE_TAG, useUnmergedTree = true).getUnclippedBoundsInRoot() + val host = composeTestRule.onNodeWithText("example.com", useUnmergedTree = true).getUnclippedBoundsInRoot() + val description = composeTestRule.onNodeWithText("A description", useUnmergedTree = true).getUnclippedBoundsInRoot() + + assertEquals(panel.top, image.top) + assertEquals(panel.left, image.left) + assertEquals(panel.right, image.right) + assertEquals(image.bottom + 10.dp, host.top) + assertEquals(panel.left + 10.dp, host.left) + assertEquals(panel.bottom - 10.dp, description.bottom) + } + @Test fun `a picture still loading keeps its slot`() { val loader = ImageLoader.Builder(androidx.test.core.app.ApplicationProvider.getApplicationContext()) From f07b81c8b06fe0ebbcfa7d830517eb079923fa9d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 18:12:04 -0400 Subject: [PATCH 24/39] feat(chat): draw a link-only message's web card without a bubble 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. --- .../internal/screens/components/MessageRow.kt | 4 +- .../shared/chat/models/ChatListItem.kt | 44 ++- .../flipcash/shared/chat/ui/LinkCardView.kt | 2 +- .../flipcash/shared/chat/ui/MessageBubble.kt | 117 ++++++ .../flipcash/shared/chat/ui/WebLinkCard.kt | 15 +- .../shared/chat/ui/WebBareCardTest.kt | 364 ++++++++++++++++++ 6 files changed, 529 insertions(+), 17 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index c9c46d47b2..3b4aaac471 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -73,7 +73,7 @@ import com.flipcash.shared.chat.ui.QuickReactionStripPopup import com.flipcash.shared.chat.ui.ReactionPillRow import com.flipcash.shared.chat.ui.bubbleMaxWidth import com.flipcash.shared.chat.ui.bubblePositionOf -import com.flipcash.shared.chat.ui.rendersBare +import com.flipcash.shared.chat.ui.rememberRendersBare import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf @@ -512,7 +512,7 @@ internal fun MessageRow( // receipt, which is every incoming one. A split message marks only its // last row. Row(verticalAlignment = Alignment.Top) { - if (item.isEdited && item.isLastRow && item.rendersBare()) { + if (item.isEdited && item.isLastRow && item.rememberRendersBare()) { Text( modifier = Modifier.padding( top = CodeTheme.dimens.grid.x1, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index da21b644d6..cde94003e6 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -266,18 +266,8 @@ fun ChatListItem.ContentBubble.splitAroundLinkCard(): List text.length || card.start >= card.end) { - return listOf(copy(linkCard = null)) - } - - // Punctuation touching the link first -- the "." ending a sentence, the brackets or quotes - // around it -- then the gap. - var leadingEnd = card.start - while (leadingEnd > 0 && text[leadingEnd - 1].isPunctuation()) leadingEnd-- - while (leadingEnd > 0 && text[leadingEnd - 1].isWhitespace()) leadingEnd-- - var trailingStart = card.end - while (trailingStart < text.length && text[trailingStart].isPunctuation()) trailingStart++ - while (trailingStart < text.length && text[trailingStart].isWhitespace()) trailingStart++ + val (leadingEnd, trailingStart) = card.marginsIn(text) + ?: return listOf(copy(linkCard = null)) val parts = listOfNotNull( text.substring(0, leadingEnd).takeIf { it.saysSomething() }?.let { MessagePart.Leading to it }, @@ -302,6 +292,36 @@ fun ChatListItem.ContentBubble.splitAroundLinkCard(): List? { + if (start < 0 || end > text.length || start >= end) return null + var leadingEnd = start + while (leadingEnd > 0 && text[leadingEnd - 1].isPunctuation()) leadingEnd-- + while (leadingEnd > 0 && text[leadingEnd - 1].isWhitespace()) leadingEnd-- + var trailingStart = end + while (trailingStart < text.length && text[trailingStart].isPunctuation()) trailingStart++ + while (trailingStart < text.length && text[trailingStart].isWhitespace()) trailingStart++ + return leadingEnd to trailingStart +} + +/** + * Whether [text] says nothing besides this card's link: what is left once the link, the + * punctuation touching it and the whitespace past that are removed is empty. The same rule + * [splitAroundLinkCard] uses to drop a text row, so a web card and a Flipcash card agree on what + * "only the link" means. + */ +internal fun LinkCard.isAloneIn(text: String): Boolean { + val (leadingEnd, trailingStart) = marginsIn(text) ?: return false + return !text.substring(0, leadingEnd).saysSomething() && !text.substring(trailingStart).saysSomething() +} + /** * The mentions lying wholly inside `[from, until)` of the message, in that row's own offsets. * A mention can only straddle a row's edge if it overlapped the card's link, and those were diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 68b51a1976..54e6490216 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -728,7 +728,7 @@ private fun StubPill(text: String) { * point of it is that it matches [bubbleShape] at the card's place in its run. */ internal val LinkCardShapeKey = SemanticsPropertyKey("LinkCardShape") -private var SemanticsPropertyReceiver.linkCardShape by LinkCardShapeKey +internal var SemanticsPropertyReceiver.linkCardShape by LinkCardShapeKey private object LinkCardDefaults { /** diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 036b68ec2c..b2268b6053 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -73,6 +73,7 @@ import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.ui.media.MediaMessageBubble import com.flipcash.shared.chat.ui.media.photoBody import com.flipcash.shared.chat.models.MessagePart +import com.flipcash.shared.chat.models.isAloneIn import com.flipcash.shared.chat.models.splitAroundLinkCard import kotlin.time.Instant import com.flipcash.shared.chat.models.SeparatorConfig @@ -162,6 +163,8 @@ fun ContentBubble( val card = item.linkCard?.takeIf { item.part == MessagePart.Card } // A web card is not split out: it draws in the text bubble under the text. val webCard = item.linkCard as? LinkCard.Web + // Whether the text is only that link; the card still has to resolve for it to draw bare. + val webCardAlone = remember(item) { item.linkOnlyWebCard() != null } Row( modifier = Modifier.fillMaxWidth(), @@ -199,6 +202,7 @@ fun ContentBubble( isEdited = isEdited, jumbo = jumbo, webCard = webCard, + webCardAlone = webCardAlone, onCardLongClick = onLongClick?.takeIf { interactive }, onCardDoubleClick = onDoubleClick?.takeIf { interactive }, interactive = interactive, @@ -280,6 +284,7 @@ fun ContentBubble( onQuoteDoubleClick = onDoubleClick?.takeIf { interactive }, jumbo = jumbo, webCard = webCard, + webCardAlone = webCardAlone, onCardLongClick = onLongClick?.takeIf { interactive }, onCardDoubleClick = onDoubleClick?.takeIf { interactive }, interactive = interactive, @@ -364,6 +369,39 @@ fun ChatListItem.ContentBubble.rendersBareEmoji(): Boolean { fun ChatListItem.ContentBubble.rendersBare(): Boolean = part == MessagePart.Card || rendersBareEmoji() +/** + * [rendersBare], plus the case only composition can answer: a message that is nothing but a web + * link whose card has resolved draws as the card alone (see [TextBubble]), so its "Edited" marker + * comes out below it like a Flipcash card row's. Asks the same [rememberWebLinkCard] the bubble + * does, so the two agree on every frame. + * + * Does not change [groupsWith]: a bare web card keeps its run, as a Flipcash card row does, because + * it is drawn in the bubble's outline. + */ +@Composable +fun ChatListItem.ContentBubble.rememberRendersBare(): Boolean { + if (rendersBare()) return true + val web = linkOnlyWebCard() ?: return false + return rememberWebLinkCard(web).resolved != null +} + +/** + * The web card of a message whose text is nothing but its link, or null. "Nothing but" is the rule + * [splitAroundLinkCard] applies to a Flipcash card (see [isAloneIn]); whether the card then draws + * bare also depends on its state, which only composition has. + */ +private fun ChatListItem.ContentBubble.linkOnlyWebCard(): LinkCard.Web? { + val web = linkCard as? LinkCard.Web ?: return null + // A photo reply draws through the media bubble, which has no web card. + val textual = when (val content = content) { + is MessageContent.Text -> true + is MessageContent.Reply -> content.photoBody() == null + else -> false + } + if (!textual || part != null) return null + return web.takeIf { plainText?.let(it::isAloneIn) == true } +} + private const val EDITED_MARKER_SLOT = "edited-marker" internal const val REPLY_QUOTE_TAG = "bubble_reply_quote" @@ -426,6 +464,7 @@ private fun TextBubble( onQuoteDoubleClick: (() -> Unit)? = null, jumbo: Boolean = false, webCard: LinkCard.Web? = null, + webCardAlone: Boolean = false, onCardLongClick: (() -> Unit)? = null, onCardDoubleClick: (() -> Unit)? = null, interactive: Boolean = true, @@ -455,6 +494,24 @@ private fun TextBubble( // answer leave the bubble text-sized. val web = webCard?.let { rememberWebLinkCard(it) } val cardDrawn = web?.resolved != null + if (web != null && cardDrawn && webCardAlone) { + BareWebCard( + web = web, + isFromSelf = isFromSelf, + position = position, + maxWidth = maxWidth, + modifier = modifier, + quote = quote, + onQuoteClick = onQuoteClick, + onQuoteLongClick = onQuoteLongClick, + onQuoteDoubleClick = onQuoteDoubleClick, + onLongClick = onCardLongClick, + onDoubleClick = onCardDoubleClick, + interactive = interactive, + attention = attention, + ) + return + } Bubble( isFromSelf, position, @@ -625,6 +682,66 @@ private fun TextBubble( } } +/** + * A resolved web card that is the whole message: no bubble and no link text, the card in the + * bubble's corners for its place in the run. The panel's own fill is the surface. + * + * Laid out as [BareLinkCard] is -- through [Bubble] with `bare`, so the jump flash and the width + * ceiling stay where every bubble gets them, and a reply's citation above the card. + * + * Only reached while the card is resolved. Should it stop being (its entry expires and the lookup + * comes back empty), [TextBubble] draws the text bubble with the link again. + */ +@Composable +private fun BareWebCard( + web: WebLinkCardState, + isFromSelf: Boolean, + position: BubblePosition, + maxWidth: Dp, + modifier: Modifier = Modifier, + quote: ChatQuote? = null, + onQuoteClick: (() -> Unit)? = null, + onQuoteLongClick: (() -> Unit)? = null, + onQuoteDoubleClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + interactive: Boolean = true, + attention: () -> Float = { 0f }, +) { + val shape = bubbleShape(position, isFromSelf) + Bubble( + isFromSelf = isFromSelf, + position = position, + maxWidth = maxWidth, + minWidth = maxWidth, + modifier = modifier, + shape = shape, + bare = true, + horizontalPadding = 0.dp, + verticalPadding = 0.dp, + attention = attention, + ) { + Column(verticalArrangement = Arrangement.spacedBy(BubbleDefaults.surroundInset)) { + if (quote != null) { + ChatQuotePanel( + quote = quote, + onClick = onQuoteClick, + onLongClick = onQuoteLongClick, + onDoubleClick = onQuoteDoubleClick, + modifier = Modifier.testTag(REPLY_QUOTE_TAG), + ) + } + WebLinkCard( + state = web, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + interactive = interactive, + bareShape = shape, + ) + } + } +} + /** * A message that is only emoji: no bubble, and the emoji drawn at something like its own size. * diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt index 4519e6c987..bdeeff79e5 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt @@ -19,9 +19,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import coil3.compose.AsyncImage @@ -30,6 +32,7 @@ import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.LocalWebLinkPreviewMode import com.flipcash.shared.chat.models.LocalWebPreviewImageLoader import com.flipcash.shared.chat.models.WebLinkPreviewMode +import com.getcode.ui.core.addIf import com.getcode.theme.CodeTheme internal const val WEB_LINK_CARD_TAG = "web_link_card" @@ -91,6 +94,10 @@ private fun chipHostOf(url: String): String = * The card under a message's text, or the chip that asks for it. The whole card is one target that * opens the page through [LocalUriHandler], so the host's "leaving Flipcash" warning applies as it * does to the link text; a long press goes to the message's own. + * + * [bareShape] set draws the card as the whole message, with no bubble around it: the panel takes the + * bubble's corners instead of its own and sits flush, with no gap above it for text that is not + * there. */ @Composable internal fun WebLinkCard( @@ -99,6 +106,7 @@ internal fun WebLinkCard( onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, interactive: Boolean = true, + bareShape: Shape? = null, ) { val resolved = state.resolved val chipHost = state.chipHost @@ -110,6 +118,7 @@ internal fun WebLinkCard( interactive = interactive, onLongClick = onLongClick, onDoubleClick = onDoubleClick, + bareShape = bareShape, ) chipHost != null -> ShowPreviewChip(host = chipHost, onClick = state.ask, modifier = modifier) @@ -123,18 +132,20 @@ private fun WebPreview( interactive: Boolean, onLongClick: (() -> Unit)?, onDoubleClick: (() -> Unit)?, + bareShape: Shape?, modifier: Modifier = Modifier, ) { // The link that was sent, never one the page named. val uriHandler = LocalUriHandler.current - val shape = RoundedCornerShape(CodeTheme.dimens.staticGrid.x2) + val shape = bareShape ?: RoundedCornerShape(CodeTheme.dimens.staticGrid.x2) Column( modifier = modifier - .padding(top = CodeTheme.dimens.staticGrid.x2) + .addIf(bareShape == null) { Modifier.padding(top = CodeTheme.dimens.staticGrid.x2) } .fillMaxWidth() .clip(shape) .background(PANEL_FILL) .testTag(WEB_LINK_CARD_TAG) + .addIf(bareShape != null) { Modifier.semantics { linkCardShape = bareShape!! } } .combinedClickable( enabled = interactive, onClick = { uriHandler.openUri(url) }, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt new file mode 100644 index 0000000000..07147f0e3c --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt @@ -0,0 +1,364 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCardResolution +import com.flipcash.shared.chat.models.LocalLinkCardResolution +import com.flipcash.shared.chat.models.LocalWebLinkPreviewMode +import com.flipcash.shared.chat.models.WebLinkPreviewMode +import com.getcode.theme.DesignSystem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * A message that is only a web link draws its resolved card with no bubble around it, in the + * bubble's corners. Anything short of a resolved card -- loading, empty, failed, the chip, the + * previews being off -- keeps the text bubble with the link, so a message never disappears. + */ +@RunWith(RobolectricTestRunner::class) +class WebBareCardTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val url = "https://www.example.com/a" + private val resolved = LinkCard.Web.State.Resolved( + title = "The title", + description = "A description", + imageUrl = null, + host = "example.com", + ) + + private class FakeResolution( + var answer: (LinkCard) -> LinkCard, + val peeked: MutableMap = mutableMapOf(), + ) : LinkCardResolution { + override val revision = MutableStateFlow(0) + override fun peek(card: LinkCard): LinkCard? = peeked[(card as LinkCard.Web).url] + override suspend fun resolve(card: LinkCard): LinkCard = answer(card) + } + + private class RecordingUriHandler : UriHandler { + val opened = mutableListOf() + override fun openUri(uri: String) { + opened += uri + } + } + + private fun answering(state: LinkCard.Web.State) = + FakeResolution({ (it as LinkCard.Web).copy(state = state) }) + + private val neverAnswers get() = FakeResolution({ it }) + + private fun webItem( + text: String = url, + isFromSelf: Boolean = false, + isEdited: Boolean = false, + content: MessageContent = MessageContent.Text(text), + quote: ChatQuote? = null, + ): ChatListItem.ContentBubble { + val start = text.indexOf(url) + return ChatListItem.ContentBubble( + messageId = 1, + contentIndex = 0, + content = content, + isFromSelf = isFromSelf, + timestamp = Instant.fromEpochSeconds(1_000), + isEdited = isEdited, + quote = quote, + linkCard = LinkCard.Web(url = url, start = start, end = start + url.length), + ) + } + + private val reply = ChatQuote( + messageId = 9, + authorName = "Ada", + snippet = ChatQuoteSnippet.Text("did you see this?"), + accent = null, + nameAccent = null, + ) + + private var expectedShape: Shape? = null + + private fun setBubble( + resolution: LinkCardResolution, + item: ChatListItem.ContentBubble = webItem(), + mode: WebLinkPreviewMode = WebLinkPreviewMode.Automatic, + position: BubblePosition = BubblePosition.Solo, + uriHandler: UriHandler = RecordingUriHandler(), + onDoubleClick: (() -> Unit)? = null, + ) { + composeTestRule.setContent { + DesignSystem { + CompositionLocalProvider( + LocalLinkCardResolution provides resolution, + LocalWebLinkPreviewMode provides mode, + LocalUriHandler provides uriHandler, + ) { + expectedShape = bubbleShape(position, item.isFromSelf) + ContentBubble( + item = item, + position = position, + onLongClick = {}, + onDoubleClick = onDoubleClick, + ) + } + } + } + } + + private val drawnBare = SemanticsMatcher.keyIsDefined(LinkCardShapeKey) + + private fun assertBare() { + composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG, useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onAllNodes(drawnBare, useUnmergedTree = true).assertCountEquals(1) + composeTestRule.onAllNodesWithText(url, substring = true).assertCountEquals(0) + } + + private fun assertInTextBubble() { + composeTestRule.onAllNodes(drawnBare, useUnmergedTree = true).assertCountEquals(0) + composeTestRule.onNodeWithText(url, substring = true).assertIsDisplayed() + } + + @Test + fun `a link-only message with a resolved card draws the card alone in the bubble's corners`() { + setBubble(answering(resolved), position = BubblePosition.First) + + composeTestRule.waitForIdle() + assertBare() + composeTestRule.onNodeWithText("The title").assertIsDisplayed() + val shape = composeTestRule.onAllNodes(drawnBare, useUnmergedTree = true) + .fetchSemanticsNodes().single().config[LinkCardShapeKey] + assertEquals(expectedShape, shape) + } + + @Test + fun `a card still loading keeps the text bubble with the link`() { + setBubble(neverAnswers) + + composeTestRule.waitForIdle() + assertInTextBubble() + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) + } + + @Test + fun `an empty answer keeps the text bubble with the link`() { + setBubble(answering(LinkCard.Web.State.None)) + + composeTestRule.waitForIdle() + assertInTextBubble() + } + + @Test + fun `the tap-to-load chip keeps the text bubble with the link`() { + setBubble(neverAnswers, mode = WebLinkPreviewMode.TapToLoad) + + composeTestRule.waitForIdle() + assertInTextBubble() + composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).assertIsDisplayed() + } + + @Test + fun `a fetch that failed after the chip keeps the text bubble with the link`() { + setBubble(neverAnswers, mode = WebLinkPreviewMode.TapToLoad) + + composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).performClick() + composeTestRule.waitForIdle() + + assertInTextBubble() + composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) + } + + @Test + fun `with previews off the message stays a plain text bubble`() { + val resolution = answering(resolved) + resolution.peeked[url] = LinkCard.Web(url, 0, url.length, resolved) + setBubble(resolution, mode = WebLinkPreviewMode.Off) + + composeTestRule.waitForIdle() + assertInTextBubble() + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) + } + + @Test + fun `text beside the link keeps the card inside the bubble under the text`() { + setBubble(answering(resolved), item = webItem(text = "look at $url now")) + + composeTestRule.waitForIdle() + assertInTextBubble() + composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG).assertIsDisplayed() + } + + @Test + fun `a link wrapped in punctuation counts as the link alone`() { + setBubble(answering(resolved), item = webItem(text = "($url).")) + + composeTestRule.waitForIdle() + composeTestRule.onAllNodes(drawnBare, useUnmergedTree = true).assertCountEquals(1) + composeTestRule.onAllNodesWithText(url, substring = true).assertCountEquals(0) + } + + @Test + fun `a card already held is drawn bare on the first frame with no bubble before it`() { + // The lookup never answers, so the held answer is all the first frame has. + val resolution = neverAnswers + resolution.peeked[url] = LinkCard.Web(url, 0, url.length, resolved) + composeTestRule.mainClock.autoAdvance = false + + setBubble(resolution) + + assertBare() + composeTestRule.onNodeWithText("The title").assertIsDisplayed() + } + + @Test + fun `a held answer that expires and comes back empty returns to the text bubble`() { + val resolution = answering(resolved) + resolution.peeked[url] = LinkCard.Web(url, 0, url.length, resolved) + setBubble(resolution) + composeTestRule.waitForIdle() + assertBare() + + resolution.peeked.clear() + resolution.answer = { (it as LinkCard.Web).copy(state = LinkCard.Web.State.None) } + resolution.revision.value += 1 + composeTestRule.waitForIdle() + + assertInTextBubble() + } + + @Test + fun `a link-only reply keeps its citation above the bare card`() { + val item = webItem( + content = MessageContent.Reply(repliedMessageId = 9, content = listOf(MessageContent.Text(url))), + quote = reply, + ) + setBubble(answering(resolved), item = item) + + composeTestRule.waitForIdle() + assertBare() + val quote = composeTestRule.onNodeWithTag(REPLY_QUOTE_TAG).getUnclippedBoundsInRoot() + val card = composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG).getUnclippedBoundsInRoot() + assertTrue(quote.bottom <= card.top, "citation $quote sits above card $card") + } + + @Test + fun `a double tap on the bare card reacts and opens nothing`() { + val handler = RecordingUriHandler() + var reacts = 0 + setBubble(answering(resolved), uriHandler = handler, onDoubleClick = { reacts++ }) + composeTestRule.waitForIdle() + assertBare() + + composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG).performTouchInput { doubleClick() } + composeTestRule.waitForIdle() + + assertEquals(1, reacts) + assertEquals(emptyList(), handler.opened) + } + + @Test + fun `a double tap on the card inside a bubble reacts and opens nothing`() { + val handler = RecordingUriHandler() + var reacts = 0 + setBubble( + answering(resolved), + item = webItem(text = "look at $url now"), + uriHandler = handler, + onDoubleClick = { reacts++ }, + ) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG).performTouchInput { doubleClick() } + composeTestRule.waitForIdle() + + assertEquals(1, reacts) + assertEquals(emptyList(), handler.opened) + } + + @Test + fun `a single tap on the bare card opens the page after the double-tap window`() { + val handler = RecordingUriHandler() + setBubble(answering(resolved), uriHandler = handler, onDoubleClick = {}) + composeTestRule.waitForIdle() + + composeTestRule.mainClock.autoAdvance = false + composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG).performClick() + assertEquals(emptyList(), handler.opened) + composeTestRule.mainClock.advanceTimeBy(1_000) + + assertEquals(listOf(url), handler.opened) + } + + @Test + fun `the tap-to-load chip acts on the first tap and takes no double tap`() { + var reacts = 0 + setBubble(neverAnswers, mode = WebLinkPreviewMode.TapToLoad, onDoubleClick = { reacts++ }) + composeTestRule.waitForIdle() + + composeTestRule.mainClock.autoAdvance = false + composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).performClick() + composeTestRule.mainClock.advanceTimeBy(1_000) + composeTestRule.mainClock.autoAdvance = true + composeTestRule.waitForIdle() + + composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) + assertEquals(0, reacts) + } + + @Test + fun `an edited bare web card is told to draw its marker below`() { + var bare: Boolean? = null + val resolution = answering(resolved) + composeTestRule.setContent { + DesignSystem { + CompositionLocalProvider(LocalLinkCardResolution provides resolution) { + bare = webItem(isEdited = true).rememberRendersBare() + } + } + } + composeTestRule.waitForIdle() + assertEquals(true, bare) + } + + @Test + fun `a web card not yet resolved does not count as bare`() { + var bare: Boolean? = null + composeTestRule.setContent { + DesignSystem { + CompositionLocalProvider(LocalLinkCardResolution provides neverAnswers) { + bare = webItem(isEdited = true).rememberRendersBare() + } + } + } + composeTestRule.waitForIdle() + assertEquals(false, bare) + } +} From ca28a2821313186c2ee66b184f667e05e59e74ba Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 18:13:51 -0400 Subject: [PATCH 25/39] fix(chat): let a cash card's claim pill act on the first tap 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. --- .../flipcash/shared/chat/ui/LinkCardView.kt | 21 +- .../shared/chat/ui/LinkCardTapTest.kt | 197 ++++++++++++++++++ 2 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardTapTest.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 54e6490216..76749cb92c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.ui import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement @@ -169,7 +170,12 @@ internal fun LinkCardView( ) { val height: Dp = maxWidth * LinkCardDefaults.CARD_ASPECT when (live) { - is LinkCard.Cash -> CashLinkCard(card = live, height = height, shape = shape) + is LinkCard.Cash -> CashLinkCard( + card = live, + height = height, + shape = shape, + onClaim = onClick?.let { click -> { click(live) } }, + ) is LinkCard.TokenInfo -> TokenLinkCard(card = live, height = height, shape = shape) // Drawn above; unreachable here. is LinkCard.GroupInvite, is LinkCard.User, is LinkCard.Web -> Unit @@ -223,6 +229,7 @@ private fun CashLinkCard( card: LinkCard.Cash, height: Dp, shape: CornerBasedShape, + onClaim: (() -> Unit)? = null, ) { // Unresolved is also the unavailable state: a lookup that failed, timed out or was switched off // renders here. The same voucher, with nothing filled in — same size, same chrome — so nothing @@ -262,7 +269,7 @@ private fun CashLinkCard( // withdraws it. That is the trade: an offer that is occasionally retracted, against a // claim that is always late. Claimed and Expired both land on a dimmed card with the // label under the tear, so the withdrawal is at least unmistakable when it happens. - StubPill(stringResource(R.string.label_linkCard_claim)) + StubPill(stringResource(R.string.label_linkCard_claim), onClick = onClaim) return@CashVoucher } when (state.claim) { @@ -277,7 +284,7 @@ private fun CashLinkCard( // link -- the bubble sits on the sender's side -- so a card that read differently for // the issuer would be saying it twice, and saying it in the one place both people are // looking at the same object. - LinkCard.Cash.Claim.Claimable -> StubPill(stringResource(R.string.label_linkCard_claim)) + LinkCard.Cash.Claim.Claimable -> StubPill(stringResource(R.string.label_linkCard_claim), onClick = onClaim) } } } @@ -708,10 +715,14 @@ private fun InkPlaceholder( * is only reporting what became of it. */ @Composable -private fun StubPill(text: String) { +private fun StubPill(text: String, onClick: (() -> Unit)? = null) { Text( modifier = Modifier - .background(LinkCardDefaults.INK, RoundedCornerShape(percent = 50)) + .clip(RoundedCornerShape(percent = 50)) + .background(LinkCardDefaults.INK) + // An explicit button acts on the first tap. The card around it waits out the double-tap + // window for the reaction; this does not, and takes no double tap of its own. + .addIf(onClick != null) { Modifier.clickable(onClick = onClick!!) } .padding( horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x1, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardTapTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardTapTest.kt new file mode 100644 index 0000000000..5fbaa4b1c2 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardTapTest.kt @@ -0,0 +1,197 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCardResolution +import com.flipcash.shared.chat.models.LocalChatActionHandler +import com.flipcash.shared.chat.models.LocalLinkCardResolution +import com.flipcash.shared.chat.models.splitAroundLinkCard +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.usdf +import com.getcode.theme.DesignSystem +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * Taps on link cards. The card body takes a double tap for the transcript's reaction and waits out + * the window before a single tap acts; an explicit button inside a card acts on the first tap. + */ +@RunWith(RobolectricTestRunner::class) +class LinkCardTapTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val cashLink = "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + private val inviteLink = "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162" + private val chatId = ChatId(ByteArray(16)) + + private val cash = LinkCard.Cash( + url = cashLink, + start = 0, + end = cashLink.length, + entropy = "KNi8pQr1n5hRU65vKJGge3", + state = LinkCard.Cash.State.Resolved( + amount = "$5.00", + claim = LinkCard.Cash.Claim.Claimable, + token = Token.usdf, + ), + ) + + private val invite = LinkCard.GroupInvite( + url = inviteLink, + start = 0, + end = inviteLink.length, + chatId = chatId, + state = LinkCard.GroupInvite.State.Resolved( + title = "Bad Boys", + picture = null, + memberCount = 3, + requirement = null, + ), + ) + + private class HeldResolution : LinkCardResolution { + override val revision = MutableStateFlow(0) + override fun peek(card: LinkCard): LinkCard = card + override suspend fun resolve(card: LinkCard): LinkCard = card + } + + private fun setCard( + card: LinkCard, + onAction: (ChatAction) -> Unit, + onDoubleClick: () -> Unit = {}, + ) { + val row = ChatListItem.ContentBubble( + messageId = 1, + contentIndex = 0, + content = MessageContent.Text(card.url), + isFromSelf = false, + timestamp = Instant.fromEpochSeconds(1_000), + linkCard = card, + ).splitAroundLinkCard().single() + composeTestRule.setContent { + DesignSystem { + CompositionLocalProvider( + LocalChatActionHandler provides onAction, + LocalLinkCardResolution provides HeldResolution(), + ) { + ContentBubble( + item = row, + position = BubblePosition.Solo, + onLongClick = {}, + onDoubleClick = onDoubleClick, + ) + } + } + } + } + + private val card = SemanticsMatcher.keyIsDefined(LinkCardShapeKey) + + @Test + fun `the claim pill acts on the first tap with no double-tap wait`() { + val actions = mutableListOf() + setCard(cash, onAction = { actions += it }) + composeTestRule.waitForIdle() + + composeTestRule.mainClock.autoAdvance = false + composeTestRule.onNodeWithText("Tap to claim").performClick() + + assertEquals( + listOf(ChatAction.CashLinkOpened(cash.entropy, cash.url)), + actions.toList(), + ) + } + + @Test + fun `a double tap on the claim pill does not react`() { + var reacts = 0 + val actions = mutableListOf() + setCard(cash, onAction = { actions += it }, onDoubleClick = { reacts++ }) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Tap to claim").performTouchInput { doubleClick() } + composeTestRule.waitForIdle() + + assertEquals(0, reacts) + } + + @Test + fun `a double tap on the cash card outside the pill reacts`() { + var reacts = 0 + val actions = mutableListOf() + setCard(cash, onAction = { actions += it }, onDoubleClick = { reacts++ }) + composeTestRule.waitForIdle() + + composeTestRule.onNode(card, useUnmergedTree = true).performTouchInput { + doubleClick(Offset(width * 0.2f, height * 0.2f)) + } + composeTestRule.waitForIdle() + + assertEquals(1, reacts) + assertEquals(emptyList(), actions.toList()) + } + + @Test + fun `a single tap on the cash card outside the pill opens it after the double-tap window`() { + val actions = mutableListOf() + setCard(cash, onAction = { actions += it }, onDoubleClick = {}) + composeTestRule.waitForIdle() + + composeTestRule.mainClock.autoAdvance = false + composeTestRule.onNode(card, useUnmergedTree = true).performTouchInput { + click(Offset(width * 0.2f, height * 0.2f)) + } + assertEquals(emptyList(), actions.toList()) + composeTestRule.mainClock.advanceTimeBy(1_000) + + assertEquals( + listOf(ChatAction.CashLinkOpened(cash.entropy, cash.url)), + actions.toList(), + ) + } + + @Test + fun `the group invite's view button acts on the first tap`() { + val actions = mutableListOf() + setCard(invite, onAction = { actions += it }) + composeTestRule.waitForIdle() + + composeTestRule.mainClock.autoAdvance = false + composeTestRule.onNodeWithText("View").performClick() + + assertEquals(listOf(ChatAction.OpenGroup(chatId)), actions.toList()) + } + + @Test + fun `a double tap on the group invite's body reacts`() { + var reacts = 0 + setCard(invite, onAction = {}, onDoubleClick = { reacts++ }) + composeTestRule.waitForIdle() + + composeTestRule.onNode(card, useUnmergedTree = true).performTouchInput { + doubleClick(Offset(width * 0.5f, height * 0.1f)) + } + composeTestRule.waitForIdle() + + assertEquals(1, reacts) + } +} From d396b64720d86c4e9aa895ebd1ef1bb227a7e758 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 19:16:06 -0400 Subject: [PATCH 26/39] test(messenger): take the D14 address ranges from the shared fixture 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. --- .../internal/link/PublicOnlyDnsTest.kt | 32 ++------- .../src/test/resources/link_metadata.json | 65 +++++++++++++++++++ 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt index 2d8f08dda1..51032e9eee 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PublicOnlyDnsTest.kt @@ -57,36 +57,18 @@ class PublicOnlyDnsTest { } } - /** `64:ff9b::/96` carries an IPv4 address in its last 32 bits; it is judged by that address (parity decision D14). */ + /** + * D14 ranges are pinned by the fixture's `addresses` rows. This covers what those rows leave out: + * the edges of each block, and the mapped forms Java may hand back as an `Inet4Address`. + */ @Test - fun `a nat64 address is judged by its embedded ipv4`() { - assertTrue(InetAddress.getByName("64:ff9b::a00:1").isPrivate()) - assertTrue(InetAddress.getByName("64:ff9b::7f00:1").isPrivate()) - assertTrue(InetAddress.getByName("64:ff9b::a9fe:a9fe").isPrivate()) - assertFalse(InetAddress.getByName("64:ff9b::808:808").isPrivate()) - assertFalse(InetAddress.getByName("2001:db8::a00:1").isPrivate()) - } - - /** Special-use IPv4 blocks beyond the fixture's rows (parity decision D14). */ - @Test - fun `special-use ipv4 ranges are private and their neighbours are not`() { - for (a in listOf("192.0.0.1", "192.0.0.255", "192.0.2.1", "198.18.0.1", "198.19.255.254", "240.0.0.1", "255.255.255.255")) { - assertTrue(InetAddress.getByName(a).isPrivate(), a) - } - for (a in listOf("192.0.1.1", "192.0.3.1", "198.17.255.255", "198.20.0.1", "239.255.255.255".let { "223.255.255.255" })) { - assertFalse(InetAddress.getByName(a).isPrivate(), a) - } - } - - @Test - fun `6to4, ipv4-compatible and mapped ipv6 are judged as the spec says`() { - for (a in listOf("2002::1", "2002:808:808::1", "::", "::1", "::a00:1", "::808:808", "::ffff:10.0.0.1", "::ffff:192.0.2.1")) { + fun `range edges and java mapped forms the fixture leaves out`() { + for (a in listOf("64:ff9b::7f00:1", "192.0.0.255", "2002::1", "::ffff:192.0.2.1")) { assertTrue(InetAddress.getByName(a).isPrivate(), a) } - for (a in listOf("::ffff:8.8.8.8", "2001:4860::8888", "2003::1")) { + for (a in listOf("2001:db8::a00:1", "192.0.1.1", "198.17.255.255", "223.255.255.255", "2001:4860::8888", "2003::1")) { assertFalse(InetAddress.getByName(a).isPrivate(), a) } - // Built from raw bytes, so a mapped form is covered whichever class Java returns. val mapped = ByteArray(16).also { it[10] = 0xFF.toByte(); it[11] = 0xFF.toByte(); it[12] = 10; it[15] = 1 } assertTrue(InetAddress.getByAddress(mapped).isPrivate()) mapped[12] = 8; mapped[13] = 8; mapped[14] = 8; mapped[15] = 8 diff --git a/apps/flipcash/features/messenger/src/test/resources/link_metadata.json b/apps/flipcash/features/messenger/src/test/resources/link_metadata.json index d9a00ab0de..03b8fd8a56 100644 --- a/apps/flipcash/features/messenger/src/test/resources/link_metadata.json +++ b/apps/flipcash/features/messenger/src/test/resources/link_metadata.json @@ -455,6 +455,71 @@ "address": "::ffff:93.184.215.14", "public": true, "note": "IPv4-mapped, judged by its public IPv4" + }, + { + "address": "64:ff9b::a00:1", + "public": false, + "note": "NAT64 64:ff9b::/96, judged by 10.0.0.1" + }, + { + "address": "64:ff9b::808:808", + "public": true, + "note": "NAT64 64:ff9b::/96, judged by its public 8.8.8.8" + }, + { + "address": "2002:a00:1::", + "public": false, + "note": "6to4 2002::/16, never public" + }, + { + "address": "2002:808:808::", + "public": false, + "note": "6to4 2002::/16, never public even around a public IPv4" + }, + { + "address": "::a00:1", + "public": false, + "note": "IPv4-compatible ::/96, never public" + }, + { + "address": "::808:808", + "public": false, + "note": "IPv4-compatible ::/96, never public even around a public IPv4" + }, + { + "address": "192.0.0.8", + "public": false, + "note": "IETF protocol assignments 192.0.0.0/24" + }, + { + "address": "192.0.2.1", + "public": false, + "note": "TEST-NET-1 192.0.2.0/24" + }, + { + "address": "192.0.3.1", + "public": true, + "note": "just past 192.0.2.0/24" + }, + { + "address": "198.18.0.1", + "public": false, + "note": "benchmarking 198.18.0.0/15, low end" + }, + { + "address": "198.19.255.255", + "public": false, + "note": "benchmarking 198.18.0.0/15, high end" + }, + { + "address": "198.20.0.1", + "public": true, + "note": "just past 198.18.0.0/15" + }, + { + "address": "240.0.0.1", + "public": false, + "note": "reserved 240.0.0.0/4" } ] } From 0754b638566d810f5e43f21ae27a370ee7c355f0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 19:18:11 -0400 Subject: [PATCH 27/39] fix(messenger): end a web card's loading when its lookup fails --- .../app/messenger/internal/link/LinkCardResolver.kt | 13 +++++++------ .../messenger/internal/link/LinkCardResolverTest.kt | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt index acfc173473..f0d87a0a89 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt @@ -129,7 +129,7 @@ internal class LinkCardResolver( is LinkCard.TokenInfo -> card.copy(state = tokenState(card.mint)) is LinkCard.GroupInvite -> card.copy(state = groupState(card.chatId)) is LinkCard.User -> card.copy(state = userState(card.identity)) - is LinkCard.Web -> card.copy(state = webState(card.url) ?: card.state) + is LinkCard.Web -> card.copy(state = webState(card.url)) } /** @@ -267,11 +267,12 @@ internal class LinkCardResolver( } /** - * The answer for a web page, or null when the lookup failed and the card stays as it was. A - * failure is forgotten, never remembered or stored, so the next draw asks again. A page that - * answers is held in [memory] by its cache key, which ignores the fragment. + * The answer for a web page. A lookup that failed answers [LinkCard.Web.State.None] for this + * draw, so a card waiting on it stops loading and falls back to the link; that is forgotten, + * never remembered or stored, so the next draw asks again. A page that answers is held in + * [memory] by its cache key, which ignores the fragment. */ - private suspend fun webState(url: String): LinkCard.Web.State? { + private suspend fun webState(url: String): LinkCard.Web.State { val key = WebLinks.cacheKey(url) ?: return LinkCard.Web.State.None memory.webs[key]?.let { return it } return memoized(webQueries, key) { @@ -284,7 +285,7 @@ internal class LinkCardResolver( // hand back the expired answer instead of asking again. forget(webQueries, key) } - }.getOrNull() + }.getOrDefault(LinkCard.Web.State.None) } private suspend fun memoized( diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt index 8b3cbdc755..d6a52917f0 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt @@ -675,11 +675,11 @@ class LinkCardResolverTest { } @Test - fun `a failed web lookup is not memoized`() = runTest { + fun `a failed web lookup ends the draw as none and is not memoized`() = runTest { var calls = 0 val memory = LinkCardMemory() val resolver = webResolver(memory) { calls++; Result.failure(java.io.IOException("offline")) } - assertEquals(LinkCard.Web.State.Loading, (resolver.resolve(webCard) as LinkCard.Web).state) + assertEquals(LinkCard.Web.State.None, (resolver.resolve(webCard) as LinkCard.Web).state) assertNull(resolver.peek(webCard)) assertTrue(memory.webs.isEmpty()) val revision = resolver.revision.value From 28a6f5c64464fc3e4c0b91cecf98765a70de9534 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 19:21:04 -0400 Subject: [PATCH 28/39] feat(chat): hold a link-only message's place with a card placeholder while it loads --- .../flipcash/shared/chat/ui/MessageBubble.kt | 15 +- .../flipcash/shared/chat/ui/WebLinkCard.kt | 97 +++++++++- .../shared/chat/ui/WebBareCardTest.kt | 175 +++++++++++++++++- 3 files changed, 271 insertions(+), 16 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index b2268b6053..5a82541c24 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -371,7 +371,7 @@ fun ChatListItem.ContentBubble.rendersBare(): Boolean = /** * [rendersBare], plus the case only composition can answer: a message that is nothing but a web - * link whose card has resolved draws as the card alone (see [TextBubble]), so its "Edited" marker + * link whose card has resolved, or is loading behind a placeholder, draws as the card alone (see [TextBubble]), so its "Edited" marker * comes out below it like a Flipcash card row's. Asks the same [rememberWebLinkCard] the bubble * does, so the two agree on every frame. * @@ -382,7 +382,8 @@ fun ChatListItem.ContentBubble.rendersBare(): Boolean = fun ChatListItem.ContentBubble.rememberRendersBare(): Boolean { if (rendersBare()) return true val web = linkOnlyWebCard() ?: return false - return rememberWebLinkCard(web).resolved != null + val state = rememberWebLinkCard(web) + return state.resolved != null || state.loading } /** @@ -494,7 +495,10 @@ private fun TextBubble( // answer leave the bubble text-sized. val web = webCard?.let { rememberWebLinkCard(it) } val cardDrawn = web?.resolved != null - if (web != null && cardDrawn && webCardAlone) { + // A link-only message holds the card's place while its lookup runs, so the link never shows + // and the card then fills the same slot. An empty or failed answer ends the wait, and the text + // bubble with the link takes over. + if (web != null && webCardAlone && (cardDrawn || web.loading)) { BareWebCard( web = web, isFromSelf = isFromSelf, @@ -689,8 +693,9 @@ private fun TextBubble( * Laid out as [BareLinkCard] is -- through [Bubble] with `bare`, so the jump flash and the width * ceiling stay where every bubble gets them, and a reply's citation above the card. * - * Only reached while the card is resolved. Should it stop being (its entry expires and the lookup - * comes back empty), [TextBubble] draws the text bubble with the link again. + * Only reached while the card is resolved or its lookup is running, when [WebLinkCard] draws a + * placeholder in its place. Should the answer come back empty, [TextBubble] draws the text bubble + * with the link again. */ @Composable private fun BareWebCard( diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt index bdeeff79e5..9acade0bee 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt @@ -3,7 +3,10 @@ package com.flipcash.shared.chat.ui import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -19,14 +22,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.flipcash.app.core.ui.shimmer import com.flipcash.core.R import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.LocalWebLinkPreviewMode @@ -38,6 +45,7 @@ import com.getcode.theme.CodeTheme internal const val WEB_LINK_CARD_TAG = "web_link_card" internal const val WEB_LINK_CHIP_TAG = "web_link_chip" internal const val WEB_LINK_IMAGE_TAG = "web_link_image" +internal const val WEB_LINK_PLACEHOLDER_TAG = "web_link_placeholder" /** Width over height of the picture, the shape Open Graph images are sized for. */ private const val IMAGE_ASPECT = 1.91f @@ -45,18 +53,26 @@ private const val IMAGE_ASPECT = 1.91f private val PANEL_FILL = Color.White.copy(alpha = 0.08f) private val CHIP_FILL = Color.White.copy(alpha = 0.12f) private const val TITLE_LINES = 2 +private val TITLE_BAR_HEIGHT = 14.dp +private const val TITLE_BAR_SHORT_FRACTION = 0.6f private const val DESCRIPTION_LINES = 2 /** * What a web card draws now: [resolved] once the page has answered, [chipHost] while the viewer is - * outside the group and has not asked, and neither otherwise. A card still loading in automatic - * mode, an empty answer, and a fetch that failed after the chip was tapped all draw nothing. + * outside the group and has not asked, and neither otherwise. A card still loading, an empty + * answer, and a lookup that failed all draw nothing under a message's text. + * + * [loading] is true while a lookup is actually under way or about to be: automatic mode, or the chip + * tapped, with no answer yet. A link-only message holds its place with a placeholder for as long as + * it is, and [host] names the link's own host for it. */ @Stable internal class WebLinkCardState( val url: String, val resolved: LinkCard.Web.State.Resolved?, val chipHost: String?, + val loading: Boolean, + val host: String, val ask: () -> Unit, ) @@ -71,9 +87,10 @@ internal class WebLinkCardState( internal fun rememberWebLinkCard(card: LinkCard.Web): WebLinkCardState { val mode = LocalWebLinkPreviewMode.current var asked by rememberSaveable(card.url) { mutableStateOf(false) } + val fetching = mode == WebLinkPreviewMode.Automatic || (mode == WebLinkPreviewMode.TapToLoad && asked) val live = rememberResolvedCard( card = card, - fetch = mode == WebLinkPreviewMode.Automatic || (mode == WebLinkPreviewMode.TapToLoad && asked), + fetch = fetching, ) as? LinkCard.Web ?: card val showChip = mode == WebLinkPreviewMode.TapToLoad && !asked && live.state == LinkCard.Web.State.Loading @@ -82,12 +99,14 @@ internal fun rememberWebLinkCard(card: LinkCard.Web): WebLinkCardState { // Off draws no card at all, even from an answer held from before the flag was switched off. resolved = (live.state as? LinkCard.Web.State.Resolved)?.takeIf { mode != WebLinkPreviewMode.Off }, chipHost = if (showChip) chipHostOf(card.url) else null, + loading = fetching && live.state == LinkCard.Web.State.Loading, + host = chipHostOf(card.url), ask = { asked = true }, ) } /** The host a chip names: lower case, without a leading "www.". */ -private fun chipHostOf(url: String): String = +internal fun chipHostOf(url: String): String = Uri.parse(url).host.orEmpty().lowercase().removePrefix("www.") /** @@ -121,6 +140,17 @@ internal fun WebLinkCard( bareShape = bareShape, ) + // Only a bare card holds its place: under text, a card that has not landed draws nothing. + state.loading && bareShape != null -> WebPlaceholder( + host = state.host, + url = state.url, + modifier = modifier, + interactive = interactive, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + bareShape = bareShape, + ) + chipHost != null -> ShowPreviewChip(host = chipHost, onClick = state.ask, modifier = modifier) } } @@ -186,6 +216,65 @@ private fun WebPreview( } } +/** + * What a link-only message draws while its card loads: the card's own proportions with the page's + * parts as shimmering bars, so the real card fills the same slot. Acts as the card does -- a tap + * opens the link, a long press and a double tap go to the message -- and reads out the link itself, + * since the text it replaces is not on screen. + */ +@Composable +private fun WebPlaceholder( + host: String, + url: String, + interactive: Boolean, + onLongClick: (() -> Unit)?, + onDoubleClick: (() -> Unit)?, + bareShape: Shape, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + val bar = RoundedCornerShape(CodeTheme.dimens.staticGrid.x1) + Column( + modifier = modifier + .fillMaxWidth() + .clip(bareShape) + .background(PANEL_FILL) + .testTag(WEB_LINK_PLACEHOLDER_TAG) + .semantics { + contentDescription = url + linkCardShape = bareShape + } + .combinedClickable( + enabled = interactive, + onClick = { uriHandler.openUri(url) }, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + hapticFeedbackEnabled = false, + ), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(IMAGE_ASPECT) + .shimmer(RectangleShape), + ) + Column( + modifier = Modifier.padding(CodeTheme.dimens.staticGrid.x2), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), + ) { + Text( + text = host, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Box(Modifier.fillMaxWidth().height(TITLE_BAR_HEIGHT).shimmer(bar)) + Box(Modifier.fillMaxWidth(TITLE_BAR_SHORT_FRACTION).height(TITLE_BAR_HEIGHT).shimmer(bar)) + } + } +} + /** * The page's picture, through the preview loader only. With no loader, no picture: the app's own * loader keeps cookies and follows any redirect. A picture that fails to load takes its slot with diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt index 07147f0e3c..1f1e6d1c36 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt @@ -5,10 +5,12 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.longClick import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag @@ -27,6 +29,7 @@ import com.flipcash.shared.chat.models.LocalLinkCardResolution import com.flipcash.shared.chat.models.LocalWebLinkPreviewMode import com.flipcash.shared.chat.models.WebLinkPreviewMode import com.getcode.theme.DesignSystem +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.junit.Rule @@ -57,7 +60,7 @@ class WebBareCardTest { ) private class FakeResolution( - var answer: (LinkCard) -> LinkCard, + var answer: suspend (LinkCard) -> LinkCard, val peeked: MutableMap = mutableMapOf(), ) : LinkCardResolution { override val revision = MutableStateFlow(0) @@ -142,6 +145,17 @@ class WebBareCardTest { composeTestRule.onAllNodesWithText(url, substring = true).assertCountEquals(0) } + private fun assertPlaceholderInPlaceOfBubble() { + composeTestRule.onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG, useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onAllNodes(drawnBare, useUnmergedTree = true).assertCountEquals(1) + composeTestRule.onAllNodesWithText(url, substring = true).assertCountEquals(0) + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG, useUnmergedTree = true).assertCountEquals(0) + } + + private fun assertNoPlaceholder() { + composeTestRule.onAllNodesWithTag(WEB_LINK_PLACEHOLDER_TAG, useUnmergedTree = true).assertCountEquals(0) + } + private fun assertInTextBubble() { composeTestRule.onAllNodes(drawnBare, useUnmergedTree = true).assertCountEquals(0) composeTestRule.onNodeWithText(url, substring = true).assertIsDisplayed() @@ -160,12 +174,145 @@ class WebBareCardTest { } @Test - fun `a card still loading keeps the text bubble with the link`() { - setBubble(neverAnswers) + fun `a card still loading holds its place with a placeholder and no link text`() { + setBubble(neverAnswers, position = BubblePosition.First) + + composeTestRule.waitForIdle() + assertPlaceholderInPlaceOfBubble() + val node = composeTestRule.onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG, useUnmergedTree = true) + .fetchSemanticsNode() + assertEquals(listOf(url), node.config[SemanticsProperties.ContentDescription]) + assertEquals(expectedShape, node.config[LinkCardShapeKey]) + // The host comes from the link that was sent, without its "www.". + composeTestRule.onNodeWithText("example.com").assertIsDisplayed() + } + + @Test + fun `a loading card that resolves swaps to the card in place`() { + val gate = CompletableDeferred() + setBubble(FakeResolution({ gate.await(); (it as LinkCard.Web).copy(state = resolved) })) + composeTestRule.waitForIdle() + assertPlaceholderInPlaceOfBubble() + + val placeholderBounds = composeTestRule + .onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG, useUnmergedTree = true).getUnclippedBoundsInRoot() + gate.complete(Unit) composeTestRule.waitForIdle() + + assertBare() + assertNoPlaceholder() + // Same slot: the card takes the width and the corner of the panel it replaces. + val cardBounds = composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG).getUnclippedBoundsInRoot() + assertEquals(placeholderBounds.right - placeholderBounds.left, cardBounds.right - cardBounds.left) + assertEquals(placeholderBounds.left, cardBounds.left) + } + + @Test + fun `a loading card that comes back empty falls back to the text bubble with the link`() { + val gate = CompletableDeferred() + setBubble(FakeResolution({ gate.await(); (it as LinkCard.Web).copy(state = LinkCard.Web.State.None) })) + composeTestRule.waitForIdle() + assertPlaceholderInPlaceOfBubble() + + gate.complete(Unit) + composeTestRule.waitForIdle() + assertInTextBubble() - composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) + assertNoPlaceholder() + } + + @Test + fun `an answer held as empty draws the text bubble on the first frame with no placeholder`() { + val resolution = neverAnswers + resolution.peeked[url] = LinkCard.Web(url, 0, url.length, LinkCard.Web.State.None) + composeTestRule.mainClock.autoAdvance = false + + setBubble(resolution) + + assertInTextBubble() + assertNoPlaceholder() + } + + @Test + fun `text beside a loading link keeps the text bubble and no placeholder`() { + setBubble(neverAnswers, item = webItem(text = "look at $url now")) + + composeTestRule.waitForIdle() + assertInTextBubble() + assertNoPlaceholder() + } + + @Test + fun `a placeholder opens the link on a single tap after the double-tap window`() { + val handler = RecordingUriHandler() + setBubble(neverAnswers, uriHandler = handler, onDoubleClick = {}) + composeTestRule.waitForIdle() + + composeTestRule.mainClock.autoAdvance = false + composeTestRule.onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG).performClick() + composeTestRule.mainClock.advanceTimeBy(1_000) + + assertEquals(listOf(url), handler.opened) + } + + @Test + fun `a double tap on the placeholder reacts and opens nothing`() { + val handler = RecordingUriHandler() + var reacts = 0 + setBubble(neverAnswers, uriHandler = handler, onDoubleClick = { reacts++ }) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG).performTouchInput { doubleClick() } + composeTestRule.waitForIdle() + + assertEquals(1, reacts) + assertEquals(emptyList(), handler.opened) + } + + @Test + fun `a long press on the placeholder acts on the message`() { + var longPresses = 0 + composeTestRule.setContent { + DesignSystem { + CompositionLocalProvider( + LocalLinkCardResolution provides neverAnswers, + LocalWebLinkPreviewMode provides WebLinkPreviewMode.Automatic, + ) { + ContentBubble(item = webItem(), position = BubblePosition.Solo, onLongClick = { longPresses++ }) + } + } + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG).performTouchInput { longClick() } + composeTestRule.waitForIdle() + + assertEquals(1, longPresses) + } + + @Test + fun `the chip shows no placeholder, and tapping it holds the place with one`() { + setBubble(neverAnswers, mode = WebLinkPreviewMode.TapToLoad) + composeTestRule.waitForIdle() + assertInTextBubble() + assertNoPlaceholder() + composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).assertIsDisplayed() + + composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) + assertPlaceholderInPlaceOfBubble() + } + + @Test + fun `with previews off a loading link stays a plain text bubble with no placeholder`() { + setBubble(neverAnswers, mode = WebLinkPreviewMode.Off) + + composeTestRule.waitForIdle() + assertInTextBubble() + assertNoPlaceholder() } @Test @@ -187,7 +334,7 @@ class WebBareCardTest { @Test fun `a fetch that failed after the chip keeps the text bubble with the link`() { - setBubble(neverAnswers, mode = WebLinkPreviewMode.TapToLoad) + setBubble(answering(LinkCard.Web.State.None), mode = WebLinkPreviewMode.TapToLoad) composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).performClick() composeTestRule.waitForIdle() @@ -349,11 +496,11 @@ class WebBareCardTest { } @Test - fun `a web card not yet resolved does not count as bare`() { + fun `a web card that came back empty does not count as bare`() { var bare: Boolean? = null composeTestRule.setContent { DesignSystem { - CompositionLocalProvider(LocalLinkCardResolution provides neverAnswers) { + CompositionLocalProvider(LocalLinkCardResolution provides answering(LinkCard.Web.State.None)) { bare = webItem(isEdited = true).rememberRendersBare() } } @@ -361,4 +508,18 @@ class WebBareCardTest { composeTestRule.waitForIdle() assertEquals(false, bare) } + + @Test + fun `a web card still loading counts as bare so its marker clears the placeholder`() { + var bare: Boolean? = null + composeTestRule.setContent { + DesignSystem { + CompositionLocalProvider(LocalLinkCardResolution provides neverAnswers) { + bare = webItem(isEdited = true).rememberRendersBare() + } + } + } + composeTestRule.waitForIdle() + assertEquals(true, bare) + } } From 2e2cd9fac9d366d59cee2085c22553dc9d6a278b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 08:48:37 -0400 Subject: [PATCH 29/39] feat(chat): outline a bare web card so its edge shows over a dark image 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. --- .../flipcash/shared/chat/ui/WebLinkCard.kt | 8 ++ .../shared/chat/ui/WebCardOutlineTest.kt | 98 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardOutlineTest.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt index 9acade0bee..859f598466 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.ui import android.net.Uri import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -51,6 +52,9 @@ internal const val WEB_LINK_PLACEHOLDER_TAG = "web_link_placeholder" private const val IMAGE_ASPECT = 1.91f /** The app is dark only, so the panel and the chip are white at a low alpha over the bubble. */ private val PANEL_FILL = Color.White.copy(alpha = 0.08f) + +/** A bare card's outline: the group invite card's stroke, so both bare cards draw the same edge. */ +private val BARE_STROKE = GroupInviteCardDefaults.STROKE private val CHIP_FILL = Color.White.copy(alpha = 0.12f) private const val TITLE_LINES = 2 private val TITLE_BAR_HEIGHT = 14.dp @@ -172,6 +176,9 @@ private fun WebPreview( modifier = modifier .addIf(bareShape == null) { Modifier.padding(top = CodeTheme.dimens.staticGrid.x2) } .fillMaxWidth() + // A bare card has no bubble for an edge, and a dark opaque page image can match the + // chat's own colour; the outline is drawn over the image so the edge always shows. + .addIf(bareShape != null) { Modifier.border(CodeTheme.dimens.border, BARE_STROKE, shape) } .clip(shape) .background(PANEL_FILL) .testTag(WEB_LINK_CARD_TAG) @@ -237,6 +244,7 @@ private fun WebPlaceholder( Column( modifier = modifier .fillMaxWidth() + .border(CodeTheme.dimens.border, BARE_STROKE, bareShape) .clip(bareShape) .background(PANEL_FILL) .testTag(WEB_LINK_PLACEHOLDER_TAG) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardOutlineTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardOutlineTest.kt new file mode 100644 index 0000000000..53489bee27 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardOutlineTest.kt @@ -0,0 +1,98 @@ +package com.flipcash.shared.chat.ui + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.flipcash.shared.chat.models.LinkCard +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * L6: a bare web card draws an outline, so its edge shows against the chat even when the page's + * image is opaque and close to the chat's own colour (nhl.com's is). A card inside a bubble has the + * bubble for an edge and draws none. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w402dp-h600dp-xhdpi") +class WebCardOutlineTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val url = "https://www.example.com/a" + private val resolved = LinkCard.Web.State.Resolved( + title = "The title", + description = "A description", + imageUrl = null, + host = "example.com", + ) + + private fun state(resolved: LinkCard.Web.State.Resolved?, loading: Boolean) = WebLinkCardState( + url = url, + resolved = resolved, + chipHost = null, + loading = loading, + host = "example.com", + ask = {}, + ) + + /** + * The pixel at mid width on the panel's bottom row, and one a few rows above it. Both sit on the + * text area's plain fill (no shimmer or text that low), so they differ only if an edge is drawn. + */ + private fun edgeAndInside(state: WebLinkCardState, bare: Boolean): Pair { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + DesignSystem { + Box(modifier = Modifier.background(Color.Black).padding(40.dp)) { + WebLinkCard( + state = state, + modifier = Modifier.width(300.dp), + bareShape = if (bare) RoundedCornerShape(18.dp) else null, + ) + } + } + } + repeat(5) { composeRule.mainClock.advanceTimeByFrame() } + val tag = if (state.loading) WEB_LINK_PLACEHOLDER_TAG else WEB_LINK_CARD_TAG + val pixels = composeRule.onNodeWithTag(tag).captureToImage().toPixelMap() + val x = pixels.width / 2 + return pixels[x, pixels.height - 1] to pixels[x, pixels.height - 4] + } + + @Test + fun `a bare resolved card draws an edge unlike its panel`() { + val (edge, inside) = edgeAndInside(state(resolved, loading = false), bare = true) + assertNotEquals(inside, edge, "the bottom row should be the outline, not the panel") + } + + @Test + fun `a bare placeholder draws the same edge`() { + val (edge, inside) = edgeAndInside(state(null, loading = true), bare = true) + assertNotEquals(inside, edge, "the bottom row should be the outline, not the panel") + } + + @Test + fun `a card inside a bubble draws no outline`() { + val (edge, inside) = edgeAndInside(state(resolved, loading = false), bare = false) + assertEquals(inside, edge, "an in-bubble card's bottom row should be its panel") + } +} From 1f471aed3a92ae1dc7e131507dd2eac18f4015f7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 08:58:30 -0400 Subject: [PATCH 30/39] test(chat): guard that a cached preview image draws on the card's first frame --- .../chat/ui/WebCardImageFirstFrameTest.kt | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardImageFirstFrameTest.kt diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardImageFirstFrameTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardImageFirstFrameTest.kt new file mode 100644 index 0000000000..4b726ab478 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardImageFirstFrameTest.kt @@ -0,0 +1,102 @@ +package com.flipcash.shared.chat.ui + +import android.graphics.Bitmap +import android.graphics.Color as AndroidColor +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import coil3.ImageLoader +import coil3.Uri +import coil3.asImage +import coil3.annotation.ExperimentalCoilApi +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.request.ImageRequest +import coil3.request.Options +import coil3.size.Size +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LocalWebPreviewImageLoader +import com.getcode.theme.DesignSystem +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals + +/** + * P22a: a picture already in the loader's memory cache is on the card's first frame, with no + * wait on a fetch. The fetcher serves the picture once, to warm the cache, and hangs after that, + * so anything drawn can only have come from memory. + */ +@OptIn(ExperimentalCoilApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w402dp-h600dp-xhdpi") +class WebCardImageFirstFrameTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val imageUrl = "https://img.example.com/p.png" + private val fetches = AtomicInteger() + + private val factory = object : Fetcher.Factory { + override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher = Fetcher { + if (fetches.incrementAndGet() > 1) awaitCancellation() + val bitmap = Bitmap.createBitmap(1200, 628, Bitmap.Config.ARGB_8888).apply { + eraseColor(AndroidColor.RED) + } + ImageFetchResult(bitmap.asImage(), isSampled = false, dataSource = DataSource.NETWORK) as FetchResult + } + } + + @Test + fun `an image already in memory is drawn on the card's first frame`() { + val context = ApplicationProvider.getApplicationContext() + val loader = ImageLoader.Builder(context) + .components { add(factory) } + .diskCache(null) + .build() + runBlocking { loader.execute(ImageRequest.Builder(context).data(imageUrl).size(Size.ORIGINAL).build()) } + assertEquals(1, fetches.get(), "the warm-up should have fetched once") + + val state = WebLinkCardState( + url = "https://www.example.com/a", + resolved = LinkCard.Web.State.Resolved("The title", "A description", imageUrl, "example.com"), + chipHost = null, + loading = false, + host = "example.com", + ask = {}, + ) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + DesignSystem { + CompositionLocalProvider(LocalWebPreviewImageLoader provides loader) { + Box(Modifier.width(300.dp)) { WebLinkCard(state = state) } + } + } + } + composeRule.mainClock.advanceTimeByFrame() + + val pixels = composeRule.onNodeWithTag(WEB_LINK_IMAGE_TAG, useUnmergedTree = true) + .captureToImage().toPixelMap() + val centre = pixels[pixels.width / 2, pixels.height / 2] + assertEquals(1f, centre.red, 0.02f, "the picture should be drawn, not the placeholder tint") + assertEquals(0f, centre.green, 0.02f) + } +} From 1da29f2a3bfac923b6bdfac65f9fb288be58535c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 09:04:58 -0400 Subject: [PATCH 31/39] feat(messenger): keep link preview images on disk with their rows --- .../internal/link/CoilWebImageStore.kt | 10 ++ .../messenger/internal/link/LinkCardMemory.kt | 6 + .../internal/link/LinkCardMemoryModule.kt | 18 +- .../internal/link/PersistedLinkCardMemory.kt | 31 +++- .../messenger/internal/link/WebImageLoader.kt | 60 ++++++- .../messenger/internal/link/WebImageStore.kt | 10 ++ .../link/PersistedLinkCardMemoryTest.kt | 155 +++++++++++++++++- .../internal/link/WebImageLoaderTest.kt | 114 +++++++++++++ .../app/persistence/dao/LinkPreviewDao.kt | 4 + .../app/persistence/dao/LinkPreviewDaoTest.kt | 8 + .../sources/LinkPreviewDataSource.kt | 16 +- 11 files changed, 417 insertions(+), 15 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/CoilWebImageStore.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageStore.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoaderTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/CoilWebImageStore.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/CoilWebImageStore.kt new file mode 100644 index 0000000000..b7a3881e0e --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/CoilWebImageStore.kt @@ -0,0 +1,10 @@ +package com.flipcash.app.messenger.internal.link + +import coil3.disk.DiskCache + +/** [WebImageStore] over the preview loader's [DiskCache]. */ +internal class CoilWebImageStore(private val diskCache: DiskCache?) : WebImageStore { + override fun remove(url: String) { + diskCache?.remove(url) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt index 0d54357f45..f822f0c057 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt @@ -64,6 +64,12 @@ internal open class LinkCardMemory( _webEntries[key] = Stored(state, at) } + /** The held answer for [key] whatever its age: an expired one still names a picture on disk. */ + protected fun heldWeb(key: String): LinkCard.Web.State? = _webEntries[key]?.state + + /** Every held answer whatever its age, by key. */ + protected fun heldWebs(): Map = _webEntries.mapValues { it.value.state } + protected fun clearWebs() = _webEntries.clear() protected val _tokens = ConcurrentHashMap() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt index f1f929a7b8..58f96f3f8c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.link import android.content.Context import coil3.ImageLoader +import coil3.disk.DiskCache import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.sources.LinkPreviewDataSource @@ -51,8 +52,9 @@ internal abstract class LinkCardMemoryModule { store: LinkPreviewDataSource, userManager: UserManager, resources: ResourceHelper, + images: WebImageStore, dispatchers: DispatcherProvider, - ): PersistedLinkCardMemory = PersistedLinkCardMemory(store, userManager, resources, dispatchers) + ): PersistedLinkCardMemory = PersistedLinkCardMemory(store, userManager, resources, images, dispatchers) // Not the app's singleton client: it logs bodies at Level.BODY and keeps cookies. @Provides @@ -67,13 +69,25 @@ internal abstract class LinkCardMemoryModule { // The interceptor runs on OkHttp's own thread, where blocking for a flag read is fine. webImageClient(enabled = { runBlocking { flags.get(FeatureFlag.WebLinkPreviews) } }) + @Provides + @Singleton + @WebPreviewImages + fun provideWebPreviewDiskCache(@ApplicationContext context: Context): DiskCache = + webPreviewDiskCache(context) + @Provides @Singleton @WebPreviewImages fun provideWebPreviewImageLoader( @ApplicationContext context: Context, @WebPreviewImages client: OkHttpClient, - ): ImageLoader = webPreviewImageLoader(context, client) + @WebPreviewImages diskCache: DiskCache, + ): ImageLoader = webPreviewImageLoader(context, client, diskCache) + + @Provides + @Singleton + fun provideWebImageStore(@WebPreviewImages diskCache: DiskCache): WebImageStore = + CoilWebImageStore(diskCache) @Provides @Singleton diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt index 6cb0d7741a..860d15fb20 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt @@ -48,6 +48,7 @@ internal class PersistedLinkCardMemory( private val store: LinkPreviewDataSource, private val userManager: UserManager, private val resources: ResourceHelper, + private val images: WebImageStore, dispatchers: DispatcherProvider, private val now: () -> Long = { Clock.System.now().toEpochMilliseconds() }, ) : LinkCardMemory(clock = now) { @@ -60,13 +61,20 @@ internal class PersistedLinkCardMemory( init { scope.launch { - store.observeAll(writtenSince = { now() - MAX_AGE.inWholeMilliseconds }).collect { records -> + store.observeAll( + writtenSince = { now() - MAX_AGE.inWholeMilliseconds }, + onDropped = { dropped -> dropped.forEach { removeImageOf(it) } }, + ).collect { records -> loaded.value = false + val held = heldWebs() _groups.clear() _users.clear() clearWebs() writtenAt.clear() records.forEach { load(it) } + // A database closing or changing hands: what the last one held and this one does not. + val kept = heldWebs().values.mapNotNullTo(mutableSetOf()) { imageOf(it) } + held.values.mapNotNull { imageOf(it) }.filter { it !in kept }.forEach { removeImage(it) } loaded.value = true trace(tag = TAG, message = "Loaded ${records.size} link previews", type = TraceType.Process) } @@ -92,7 +100,9 @@ internal class PersistedLinkCardMemory( override fun putWeb(key: String, state: LinkCard.Web.State) { val rowKey = WEB_PREFIX + key val unchanged = webs[key] == state + val replaced = heldWeb(key)?.let { imageOf(it) } storeWeb(key, state) + if (replaced != null && replaced != imageOf(state)) scope.launch { removeImage(replaced) } if (unchanged && !rewriteDue(rowKey)) return write(rowKey, webJson.encodeToString(StoredWeb.serializer(), StoredWeb.of(state))) } @@ -108,6 +118,20 @@ internal class PersistedLinkCardMemory( private fun rewriteDue(key: String): Boolean = now() - (writtenAt[key] ?: 0L) >= REWRITE_AFTER.inWholeMilliseconds + private fun imageOf(state: LinkCard.Web.State): String? = (state as? LinkCard.Web.State.Resolved)?.imageUrl + + private fun removeImage(url: String) { + runCatching { images.remove(url) } + .onFailure { trace(tag = TAG, message = "Failed to remove an image", error = it) } + } + + /** A row dropped for age by the table, which has already deleted it: only its picture is left. */ + private fun removeImageOf(record: LinkPreviewRecord) { + if (!record.key.startsWith(WEB_PREFIX)) return + runCatching { webJson.decodeFromString(StoredWeb.serializer(), record.json).imageUrl } + .getOrNull()?.let { removeImage(it) } + } + private fun delete(key: String) { writtenAt.remove(key) scope.launch { @@ -150,7 +174,10 @@ internal class PersistedLinkCardMemory( // longer decodes, and the link is looked up again the ordinary way. val age = (now() - record.updatedAt).milliseconds val ttl = if (state is LinkCard.Web.State.Resolved) WebLinks.RESOLVED_TTL else WebLinks.EMPTY_TTL - if (age > ttl) error("stale web row") + if (age > ttl) { + imageOf(state)?.let { removeImage(it) } + error("stale web row") + } storeWeb(record.key.removePrefix(WEB_PREFIX), state, at = record.updatedAt) } else -> error("unknown key") diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt index 5d6129bcc5..2518d4fdb9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt @@ -2,18 +2,68 @@ package com.flipcash.app.messenger.internal.link import android.content.Context import coil3.ImageLoader +import coil3.annotation.ExperimentalCoilApi +import coil3.disk.DiskCache +import coil3.network.CacheStrategy +import coil3.network.NetworkRequest +import coil3.network.NetworkResponse +import coil3.request.Options import coil3.network.okhttp.OkHttpNetworkFetcherFactory import okhttp3.OkHttpClient +import okio.Path.Companion.toOkioPath +import java.io.File + +/** The most the preview pictures may take on disk; Coil drops the least recently used past it. */ +internal const val WEB_PREVIEW_DISK_BYTES = 20L * 1024 * 1024 + +/** + * The preview pictures' own store, next to and apart from the app loader's `image_cache`, so a + * picture of an outside page never sits in a store the rest of the app reads. + */ +internal fun webPreviewDiskCache(context: Context): DiskCache = + DiskCache.Builder() + .directory(File(context.cacheDir, "web_preview_images").toOkioPath()) + .maxSizeBytes(WEB_PREVIEW_DISK_BYTES) + .build() /** * The loader for a preview's picture, on [client] from [webImageClient] and nothing else: it shares * neither the app's loader nor that loader's client, which keeps cookies and follows any redirect. * - * No disk cache. The default directory is the app loader's, and a picture of an outside page has - * no business in a store the rest of the app reads. + * Pictures are kept in [diskCache], a store of the preview's own, under [WebImageCacheStrategy]. */ -internal fun webPreviewImageLoader(context: Context, client: OkHttpClient): ImageLoader = +internal fun webPreviewImageLoader( + context: Context, + client: OkHttpClient, + diskCache: DiskCache = webPreviewDiskCache(context), +): ImageLoader = ImageLoader.Builder(context) - .components { add(OkHttpNetworkFetcherFactory(callFactory = { client })) } - .diskCache(null) + .components { add(OkHttpNetworkFetcherFactory(callFactory = { client }, cacheStrategy = { WebImageCacheStrategy })) } + .diskCache(diskCache) .build() + +/** + * What goes to and comes from the preview store. A stored picture is always read, with no check of + * its age or headers: its row decides how long it is wanted, and the picture goes when the row does. + * Only a 2xx answer is written. Coil's default also keeps a 301, 404, 410 and a few more, for a + * response cache to replay; a preview has no use for a stored failure. + */ +@OptIn(ExperimentalCoilApi::class) +internal object WebImageCacheStrategy : CacheStrategy { + override suspend fun read( + cacheResponse: NetworkResponse, + networkRequest: NetworkRequest, + options: Options, + ) = CacheStrategy.ReadResult(cacheResponse) + + override suspend fun write( + cacheResponse: NetworkResponse?, + networkRequest: NetworkRequest, + networkResponse: NetworkResponse, + options: Options, + ) = if (networkResponse.code in 200 until 300) { + CacheStrategy.WriteResult(networkResponse) + } else { + CacheStrategy.WriteResult.DISABLED + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageStore.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageStore.kt new file mode 100644 index 0000000000..28fe24f338 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageStore.kt @@ -0,0 +1,10 @@ +package com.flipcash.app.messenger.internal.link + +/** + * The preview pictures kept on disk, as the row store sees them: it only ever needs to drop one. + * An interface so a test of the rows needs no Coil disk. + */ +internal interface WebImageStore { + /** Deletes the picture stored for [url], the resolved `imageUrl` of a row. A missing one is fine. */ + fun remove(url: String) +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt index 8fccaafc69..f7a0bc4fab 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt @@ -60,6 +60,9 @@ class PersistedLinkCardMemoryTest { val rows = linkedMapOf() val writes = mutableListOf() val cutoffs = mutableListOf() + + /** Rows the table itself drops for age when it is read, as `observeAll` reports them. */ + var droppedByAge: List = emptyList() val opened = MutableStateFlow>(emptyList()) fun reopen() { @@ -67,8 +70,9 @@ class PersistedLinkCardMemoryTest { } val source: LinkPreviewDataSource = mock { - on { observeAll(any()) } doAnswer { + on { observeAll(any(), any()) } doAnswer { cutoffs += it.getArgument<() -> Long>(0)() + if (droppedByAge.isNotEmpty()) it.getArgument<(List) -> Unit>(1)(droppedByAge) opened } onBlocking { upsert(any()) } doSuspendableAnswer { @@ -85,10 +89,20 @@ class PersistedLinkCardMemoryTest { private var clock = 100.days.inWholeMilliseconds + private class RecordingImages : WebImageStore { + val removed = mutableListOf() + override fun remove(url: String) { + removed += url + } + } + + private val images = RecordingImages() + private fun TestScope.memory(table: FakeTable, viewer: List? = myId) = PersistedLinkCardMemory( store = table.source, userManager = mock { on { accountId } doReturn viewer }, resources = mock(), + images = images, dispatchers = TestDispatcherProvider(StandardTestDispatcher(testScheduler)), now = { clock }, ) @@ -332,4 +346,143 @@ class PersistedLinkCardMemoryTest { clock += 1 assertNull(loaded.webs[webKey]) } + + private fun webRow(key: String, state: LinkCard.Web.State.Resolved, updatedAt: Long) = LinkPreviewRecord( + key = "web:$key", + json = """{"title":"${state.title}","description":null,"imageUrl":${state.imageUrl?.let { "\"$it\"" }},"host":"${state.host}"}""", + updatedAt = updatedAt, + ) + + @Test + fun `a stale resolved row takes its picture with it when the table is read`() = runTest { + val table = FakeTable() + table.rows["web:$webKey"] = webRow(webKey, webResolved, updatedAt = clock - 169.hours.inWholeMilliseconds) + table.reopen() + + memory(table) + advanceUntilIdle() + + assertTrue(table.rows.isEmpty()) + assertEquals(listOf(webResolved.imageUrl), images.removed) + } + + @Test + fun `a fresh resolved row keeps its picture`() = runTest { + val table = FakeTable() + table.rows["web:$webKey"] = webRow(webKey, webResolved, updatedAt = clock - 167.hours.inWholeMilliseconds) + table.reopen() + + memory(table) + advanceUntilIdle() + + assertTrue(images.removed.isEmpty()) + } + + @Test + fun `a row the table drops for age takes its picture with it`() = runTest { + val table = FakeTable() + table.droppedByAge = listOf(webRow(webKey, webResolved, updatedAt = clock - 31.days.inWholeMilliseconds)) + table.reopen() + + memory(table) + advanceUntilIdle() + + assertEquals(listOf(webResolved.imageUrl), images.removed) + } + + @Test + fun `a group row dropped for age removes no picture`() = runTest { + val table = FakeTable() + table.droppedByAge = listOf(LinkPreviewRecord("group:ab", "{}", updatedAt = 0)) + table.reopen() + + memory(table) + advanceUntilIdle() + + assertTrue(images.removed.isEmpty()) + } + + @Test + fun `an answer replaced by none removes the picture of the one it replaced`() = runTest { + val table = FakeTable() + val memory = memory(table) + advanceUntilIdle() + memory.putWeb(webKey, webResolved) + advanceUntilIdle() + + memory.putWeb(webKey, LinkCard.Web.State.None) + advanceUntilIdle() + + assertEquals(listOf(webResolved.imageUrl), images.removed) + } + + @Test + fun `an answer replaced with a different picture removes the old one only`() = runTest { + val table = FakeTable() + val memory = memory(table) + advanceUntilIdle() + memory.putWeb(webKey, webResolved) + advanceUntilIdle() + + memory.putWeb(webKey, webResolved.copy(imageUrl = "https://example.com/j.png")) + advanceUntilIdle() + + assertEquals(listOf(webResolved.imageUrl), images.removed) + } + + @Test + fun `an answer put again with the same picture removes nothing`() = runTest { + val table = FakeTable() + val memory = memory(table) + advanceUntilIdle() + memory.putWeb(webKey, webResolved) + memory.putWeb(webKey, webResolved.copy(title = "Retitled")) + advanceUntilIdle() + + assertTrue(images.removed.isEmpty()) + } + + @Test + fun `an answer with no picture replaced by one removes nothing`() = runTest { + val table = FakeTable() + val memory = memory(table) + advanceUntilIdle() + memory.putWeb(webKey, webResolved.copy(imageUrl = null)) + memory.putWeb(webKey, webResolved) + advanceUntilIdle() + + assertTrue(images.removed.isEmpty()) + } + + @Test + fun `a database closing removes the pictures of the answers it held`() = runTest { + val table = FakeTable() + val memory = memory(table) + advanceUntilIdle() + memory.putWeb(webKey, webResolved) + advanceUntilIdle() + table.reopen() + advanceUntilIdle() + assertTrue(images.removed.isEmpty()) + + // Logged out: no database, so no rows, so no pictures to read. + table.opened.value = emptyList() + advanceUntilIdle() + + assertEquals(listOf(webResolved.imageUrl), images.removed) + } + + @Test + fun `a reload that still has the row keeps its picture`() = runTest { + val table = FakeTable() + val memory = memory(table) + advanceUntilIdle() + memory.putWeb(webKey, webResolved) + advanceUntilIdle() + + table.reopen() + advanceUntilIdle() + + assertTrue(images.removed.isEmpty()) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoaderTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoaderTest.kt new file mode 100644 index 0000000000..deef75b6e0 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoaderTest.kt @@ -0,0 +1,114 @@ +package com.flipcash.app.messenger.internal.link + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import coil3.ImageLoader +import coil3.disk.DiskCache +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import coil3.size.Size +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * P22b and P22c: the preview loader keeps pictures in a store of its own, 20 MB, written only for + * a successful answer and read whatever the headers said. The client here is a plain scripted one, + * so these cases see Coil's own cache strategy and not the rules [WebImageInterceptor] adds. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class WebImageLoaderTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val url = "https://img.example.com/p.png" + private val calls = AtomicInteger() + + private fun client(code: Int = 200, headers: Map = emptyMap()) = OkHttpClient.Builder() + .addInterceptor { chain -> + calls.incrementAndGet() + Response.Builder() + .request(chain.request()).protocol(Protocol.HTTP_1_1).code(code).message("m") + .apply { headers.forEach { (k, v) -> header(k, v) } } + .body(byteArrayOf(1, 2, 3, 4).toResponseBody("image/png".toMediaType())) + .build() + } + .build() + + private fun loader(client: OkHttpClient): ImageLoader = webPreviewImageLoader(context, client) + + private fun ImageLoader.load() { + runBlocking { execute(ImageRequest.Builder(context).data(url).size(Size.ORIGINAL) + .memoryCachePolicy(CachePolicy.DISABLED).build()) } + } + + private fun ImageLoader.stored(): DiskCache.Snapshot? = diskCache?.openSnapshot(url) + + @Test + fun `the loader's disk cache is its own, 20 MB, under the cache dir`() { + val cache = assertNotNull(loader(client()).diskCache) + + assertEquals(20L * 1024 * 1024, cache.maxSize) + assertEquals(File(context.cacheDir, "web_preview_images").absolutePath, cache.directory.toFile().absolutePath) + assertNotEquals(File(context.cacheDir, "image_cache").absolutePath, cache.directory.toFile().absolutePath) + } + + @Test + fun `a successful answer is written under the image url`() { + val loader = loader(client()) + loader.load() + + assertNotNull(loader.stored()) + } + + @Test + fun `a failed answer is never written`() { + for (code in listOf(404, 410, 301, 500)) { + val loader = loader(client(code = code)) + loader.load() + + assertNull(loader.stored(), "HTTP $code should leave nothing on disk") + } + } + + @Test + fun `no-store does not keep a picture off the disk`() { + val loader = loader(client(headers = mapOf("Cache-Control" to "no-store"))) + loader.load() + + assertNotNull(loader.stored()) + } + + @Test + fun `a stored picture is read again without a request whatever its headers said`() { + val loader = loader(client(headers = mapOf("Cache-Control" to "max-age=0, must-revalidate"))) + loader.load() + loader.load() + + assertEquals(1, calls.get()) + } + + @Test + fun `removing a url from the store deletes its picture`() { + val loader = loader(client()) + loader.load() + assertNotNull(loader.stored()) + + CoilWebImageStore(loader.diskCache).remove(url) + + assertNull(loader.stored()) + } +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/LinkPreviewDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/LinkPreviewDao.kt index e43a2d0652..6a37dd5e8b 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/LinkPreviewDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/LinkPreviewDao.kt @@ -19,6 +19,10 @@ interface LinkPreviewDao { @Query("DELETE FROM link_previews WHERE `key` = :key") suspend fun delete(key: String) + /** Every preview last written before [cutoffMillis], the ones [deleteWrittenBefore] drops. */ + @Query("SELECT * FROM link_previews WHERE updated_at < :cutoffMillis") + suspend fun getWrittenBefore(cutoffMillis: Long): List + /** Drops every preview last written before [cutoffMillis]. */ @Query("DELETE FROM link_previews WHERE updated_at < :cutoffMillis") suspend fun deleteWrittenBefore(cutoffMillis: Long) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/LinkPreviewDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/LinkPreviewDaoTest.kt index 5424406be7..2a473c1069 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/LinkPreviewDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/LinkPreviewDaoTest.kt @@ -54,6 +54,14 @@ class LinkPreviewDaoTest { assertEquals(listOf("group:new"), dao.getAll().map { it.key }) } + @Test + fun `rows written before the cutoff can be read before they are dropped`() = runTest { + dao.upsert(LinkPreviewEntity(key = "group:old", json = "{}", updatedAt = 99)) + dao.upsert(LinkPreviewEntity(key = "group:new", json = "{}", updatedAt = 100)) + + assertEquals(listOf("group:old"), dao.getWrittenBefore(100).map { it.key }) + } + @Test fun `a deleted link is gone`() = runTest { dao.upsert(LinkPreviewEntity(key = "group:ab", json = "{}", updatedAt = 1)) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/LinkPreviewDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/LinkPreviewDataSource.kt index bda2bb77d5..e241bc1121 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/LinkPreviewDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/LinkPreviewDataSource.kt @@ -40,23 +40,29 @@ class LinkPreviewDataSource @Inject constructor() { * what the reader already has. */ @OptIn(ExperimentalCoroutinesApi::class) - fun observeAll(writtenSince: () -> Long): Flow> = + fun observeAll( + writtenSince: () -> Long, + onDropped: (List) -> Unit = {}, + ): Flow> = FlipcashDatabase.observeInstance().flatMapLatest { instance -> if (instance == null) { flowOf(emptyList()) } else { flow { val dao = instance.linkPreviewDao() - dao.deleteWrittenBefore(writtenSince()) + val cutoff = writtenSince() + val dropped = dao.getWrittenBefore(cutoff) + dao.deleteWrittenBefore(cutoff) + if (dropped.isNotEmpty()) onDropped(dropped.map { it.toRecord() }) emit( - dao.getAll().map { - LinkPreviewRecord(key = it.key, json = it.json, updatedAt = it.updatedAt) - }, + dao.getAll().map { it.toRecord() }, ) } } } + private fun LinkPreviewEntity.toRecord() = LinkPreviewRecord(key = key, json = json, updatedAt = updatedAt) + suspend fun upsert(record: LinkPreviewRecord) { db?.linkPreviewDao()?.upsert( LinkPreviewEntity(key = record.key, json = record.json, updatedAt = record.updatedAt), From 2fefebe361f01e1bf5434872a509441fc292bcb2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 09:08:17 -0400 Subject: [PATCH 32/39] feat(messenger): load saved link previews at app start and before a chat's first draw --- .../kotlin/com/flipcash/app/FlipcashApp.kt | 9 ++ .../app/messenger/LinkPreviewStartup.kt | 20 +++ .../app/messenger/internal/ChatViewModel.kt | 27 ++++ .../messenger/internal/link/LinkCardMemory.kt | 3 + .../internal/link/PersistedLinkCardMemory.kt | 2 + .../internal/screens/MessengerScreen.kt | 54 ++++--- .../internal/ChatPreviewsReadyTest.kt | 148 ++++++++++++++++++ 7 files changed, 238 insertions(+), 25 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/LinkPreviewStartup.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt index 649e548a0e..67474c57e4 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt @@ -22,6 +22,7 @@ import com.getcode.opencode.repositories.EventRepository import com.getcode.utils.trace import dev.bmcreations.phantom.connect.PhantomSdk import dagger.Lazy +import com.flipcash.app.messenger.LinkPreviewStartup import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -56,6 +57,13 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader. @Inject lateinit var chatPhotoFetcher: Lazy + /** + * Built at launch so the saved link previews are reading from the database before a chat opens, + * not when the first one asks. Its init starts the read. + */ + @Inject + lateinit var linkPreviewStartup: Lazy + @Inject lateinit var workerFactory: Lazy @@ -80,6 +88,7 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader. authManager.get().init() eventRepository.get() preferredCurrencyController.get() + linkPreviewStartup.get() } // Track the foreground Activity so the tip-code share preview can render offscreen. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/LinkPreviewStartup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/LinkPreviewStartup.kt new file mode 100644 index 0000000000..290d22c839 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/LinkPreviewStartup.kt @@ -0,0 +1,20 @@ +package com.flipcash.app.messenger + +import com.flipcash.app.messenger.internal.link.LinkCardMemory +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Starts reading the saved link previews at launch. + * + * The store reads its database when it is built, and Hilt builds it on first injection, which + * without this is when a chat first opens. Injecting this from the application builds it at + * launch, so the rows are in memory before any chat is. + */ +@Singleton +class LinkPreviewStartup @Inject internal constructor( + private val memory: LinkCardMemory, +) { + /** Whether the saved previews have been read. */ + val isLoaded: Boolean get() = memory.isLoaded +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 31440031ae..8513ef3a90 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -48,6 +48,7 @@ import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.link.ClaimReplyTargets import com.flipcash.app.messenger.internal.link.LinkCardClassifier +import com.flipcash.app.messenger.internal.link.LinkCardMemory import com.flipcash.app.messenger.internal.link.LinkCardResolver import com.flipcash.app.messenger.internal.mention.activeMentionToken import com.flipcash.app.messenger.internal.mention.insertMention @@ -208,9 +209,11 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withContext data class TypingConstraints( @@ -257,6 +260,7 @@ internal class ChatViewModel @AssistedInject constructor( private val dispatchers: DispatcherProvider, // Last and defaulted so the transcript tests, which draw no pictures, need not supply one. @WebPreviewImages val webPreviewImageLoader: ImageLoader? = null, + linkCardMemory: LinkCardMemory? = null, featureFlags: FeatureFlagController = NoOpFeatureFlagController, ) : BaseViewModel( initialState = State(), @@ -270,6 +274,25 @@ internal class ChatViewModel @AssistedInject constructor( */ val webLinkPreviewsEnabled: StateFlow = featureFlags.observe(FeatureFlag.WebLinkPreviews) + /** + * Whether the transcript may be drawn. Saved link previews load at app start, but a chat opened + * straight from a notification can beat them to it, and a card drawn before its saved answer + * arrives shows its placeholder and then grows into the answer. So the first draw waits for + * them, for at most [PREVIEWS_WAIT], and then goes ahead with whatever has arrived. A store + * already loaded costs nothing, which is every chat after the first. + */ + private val _previewsReady = MutableStateFlow(linkCardMemory == null || linkCardMemory.isLoaded) + val previewsReady: StateFlow = _previewsReady.asStateFlow() + + init { + if (!_previewsReady.value && linkCardMemory != null) { + viewModelScope.launch { + withTimeoutOrNull(PREVIEWS_WAIT) { linkCardMemory.awaitLoaded() } + _previewsReady.value = true + } + } + } + /** * A photo in the composer: its id in [ChatMediaUploads] and where it came from, for the * thumbnail. A camera shot staged at the shutter carries the frame taken then as [preview], @@ -3005,6 +3028,10 @@ internal class ChatViewModel @AssistedInject constructor( } companion object { + /** The longest the first draw of a transcript waits for saved link previews to load. */ + val PREVIEWS_WAIT = 300.milliseconds + + /** * How often a visible claimable voucher is re-asked about. * diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt index f822f0c057..fb223a8552 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt @@ -107,4 +107,7 @@ internal open class LinkCardMemory( * every stored answer. Memory alone has nothing to read. */ open suspend fun awaitLoaded() = Unit + + /** Whether [awaitLoaded] would return at once. Memory alone has nothing to read, so it always would. */ + open val isLoaded: Boolean get() = true } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt index 860d15fb20..3d4583623b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt @@ -81,6 +81,8 @@ internal class PersistedLinkCardMemory( } } + override val isLoaded: Boolean get() = loaded.value + override suspend fun awaitLoaded() { loaded.first { it } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index ab6fdd7518..98ac8dfee0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -75,6 +75,7 @@ import com.flipcash.shared.common.ui.profile.PrefetchProfileCover internal fun MessengerScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val webLinkPreviewsEnabled by viewModel.webLinkPreviewsEnabled.collectAsStateWithLifecycle() + val previewsReady by viewModel.previewsReady.collectAsStateWithLifecycle() val messages = viewModel.messages.collectAsLazyPagingItems() val mediaProgress = viewModel.mediaSendProgress.collectAsStateWithLifecycle() val mediaProgressOf = remember(mediaProgress) { { mediaProgress.value } } @@ -425,31 +426,34 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { LocalChatMediaProgress provides mediaProgressOf, LocalChatPhotoSources provides photoSources, ) { - MessageList( - modifier = Modifier - .fillMaxSize() - .testTag("chat_message_list") - .softTopEdge(ChatTopEdge.blurHold(barHeight)) - .hazeSource(hazeState) - // Inside the edge blur and the haze, so a photo landing under the bars - // passes behind them the way its bubble does. - .chatPhotoTranscript( - sources = photoSources, - top = barHeight, - bottom = overlapPadding.calculateBottomPadding(), - ), - state = state, - contentPadding = overlapPadding, - messages = messages, - separatorConfig = state.separatorConfig, - otherReadPointer = otherReadPointer, - onAction = chatActionHandler, - linkCardResolution = viewModel.linkCardResolution, - webPreviewImageLoader = viewModel.webPreviewImageLoader, - webLinkPreviewsEnabled = webLinkPreviewsEnabled, - onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, - topBarBottom = barHeight, - ) + // Held for the saved link previews, 300 ms at most and the first chat only. + if (previewsReady) { + MessageList( + modifier = Modifier + .fillMaxSize() + .testTag("chat_message_list") + .softTopEdge(ChatTopEdge.blurHold(barHeight)) + .hazeSource(hazeState) + // Inside the edge blur and the haze, so a photo landing under the bars + // passes behind them the way its bubble does. + .chatPhotoTranscript( + sources = photoSources, + top = barHeight, + bottom = overlapPadding.calculateBottomPadding(), + ), + state = state, + contentPadding = overlapPadding, + messages = messages, + separatorConfig = state.separatorConfig, + otherReadPointer = otherReadPointer, + onAction = chatActionHandler, + linkCardResolution = viewModel.linkCardResolution, + webPreviewImageLoader = viewModel.webPreviewImageLoader, + webLinkPreviewsEnabled = webLinkPreviewsEnabled, + onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, + topBarBottom = barHeight, + ) + } } } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt new file mode 100644 index 0000000000..4514def554 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt @@ -0,0 +1,148 @@ +package com.flipcash.app.messenger.internal + +import android.content.ClipboardManager +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.messenger.internal.link.LinkCardMemory +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.FeaturedGroupsStore +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.Rate +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * P22d: the transcript's first draw waits for the saved link previews, up to 300 ms, so a card is + * drawn resolved rather than growing into it. Once they are loaded there is nothing to wait for. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatPreviewsReadyTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(StandardTestDispatcher()) + + private val chatCoordinator = mockk(relaxed = true) { + every { observeMediaSendProgress() } returns emptyFlow() + } + private val tipPaymentDelegate = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val contactCoordinator = mockk(relaxed = true) + private val featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)) + + init { + every { exchange.preferredRate } returns Rate.oneToOne + every { transactionController.limits } returns MutableStateFlow(null) + every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null) + } + + /** Loads [loadsAfter] after it is asked, or never. */ + private class SlowMemory(private val loadsAfter: Long?) : LinkCardMemory() { + private val done = MutableStateFlow(false) + override val isLoaded: Boolean get() = done.value + + override suspend fun awaitLoaded() { + if (loadsAfter == null) kotlinx.coroutines.awaitCancellation() + delay(loadsAfter) + done.value = true + } + } + + private class LoadedMemory : LinkCardMemory() + + private fun createViewModel( + memory: LinkCardMemory?, + ): ChatViewModel = ChatViewModel( + chatCoordinator = chatCoordinator, + mediaUploads = noMediaUploads(), + e2eePolicy = E2eePolicy(), + contactCoordinator = contactCoordinator, + contactPaymentDelegate = mockk(relaxed = true), + tipPaymentDelegate = tipPaymentDelegate, + transactionController = transactionController, + tokenCoordinator = mockk(relaxed = true), + exchange = exchange, + verifiedFiatCalculator = mockk(relaxed = true), + startChattingPayer = mockk(relaxed = true), + userManager = userManager, + resources = mockk(relaxed = true), + analytics = RecordingAnalytics(), + clipboardManager = mockk(relaxed = true), + userFlags = mockk(relaxed = true), + linkCardClassifier = mockk(relaxed = true), + linkCardResolver = mockk(relaxed = true), + cashLinkClaims = mockk(relaxed = true) { every { claimInFlight } returns MutableStateFlow(null) }, + chatCashLinks = mockk(relaxed = true), + chatDraftStore = mockk(relaxed = true), + recentReactionsStore = mockk(relaxed = true), + toastController = mockk(relaxed = true), + emojiCatalogLoader = mockk(relaxed = true), + userProfileDataSource = mockk(relaxed = true), + rosterSearch = mockk(relaxed = true), + featuredGroups = featuredGroups, + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + identifier = null, + linkCardMemory = memory, + ) + + @Test + fun `a store that never loads holds the first draw for 300 ms and then lets it through`() = runTest { + val viewModel = createViewModel(SlowMemory(loadsAfter = null)) + runCurrent() + assertFalse(viewModel.previewsReady.value, "the draw should wait at first") + + advanceTimeBy(299) + assertFalse(viewModel.previewsReady.value, "the draw should still be waiting at 299 ms") + + advanceTimeBy(1) + runCurrent() + assertTrue(viewModel.previewsReady.value, "the draw should go ahead at 300 ms with what it has") + } + + @Test + fun `a store that loads inside the wait lets the draw through when it does`() = runTest { + val viewModel = createViewModel(SlowMemory(loadsAfter = 120)) + runCurrent() + assertFalse(viewModel.previewsReady.value) + + advanceTimeBy(120) + runCurrent() + assertTrue(viewModel.previewsReady.value, "the draw should not wait out the full 300 ms") + } + + @Test + fun `a store already loaded does not hold the draw at all`() = runTest { + val viewModel = createViewModel(LoadedMemory()) + + assertTrue(viewModel.previewsReady.value, "no wait, not even a frame") + } + + @Test + fun `no store means nothing to wait for`() = runTest { + assertTrue(createViewModel(null).previewsReady.value) + } +} From 7bacd1f217985972ecc6e92bb23fb10847f95aa2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 09:33:55 -0400 Subject: [PATCH 33/39] chore(messenger): sync link_metadata fixture and raise the page body cap to 1 MiB --- .../app/messenger/internal/link/WebLinks.kt | 2 +- .../src/test/resources/link_metadata.json | 805 +++++++++++++++++- 2 files changed, 804 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt index f8a1499835..500419c102 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinks.kt @@ -6,7 +6,7 @@ import kotlin.time.Duration.Companion.seconds /** Rules shared by every step of a web link preview. Mirrors `link_metadata.json`. */ internal object WebLinks { - const val MAX_BODY_BYTES = 512 * 1024 + const val MAX_BODY_BYTES = 1024 * 1024 const val MAX_IMAGE_BYTES = 2 * 1024 * 1024 const val MAX_REDIRECTS = 3 const val MAX_CONCURRENT = 4 diff --git a/apps/flipcash/features/messenger/src/test/resources/link_metadata.json b/apps/flipcash/features/messenger/src/test/resources/link_metadata.json index 03b8fd8a56..a80051524b 100644 --- a/apps/flipcash/features/messenger/src/test/resources/link_metadata.json +++ b/apps/flipcash/features/messenger/src/test/resources/link_metadata.json @@ -1,8 +1,8 @@ { "algorithm": "link-metadata", - "note": "Generic web link previews. pages: HTML as fetched (decode as UTF-8) plus the URL after redirects, to the card's metadata or null. Only the head is read; og:title, else , is required; og:description, else meta description; og:image resolved against finalUrl and dropped unless https on an eligible host. host is finalUrl's host minus one leading www.; og:url and og:site_name are never read. hosts: whether a host may be fetched, checked on the first request and on every redirect. addresses: whether a resolved address may be connected to; both apps connect only to an address that passed, so DNS rebinding is stopped on both. cacheKeys: the persisted key is web:<key>. Behavior parity, not a computation with an external reference.", + "note": "Generic web link previews. pages: HTML as fetched (decode as UTF-8) plus the URL after redirects, to the card's metadata or null. Only the head is read; og:title, else <title>, is required; og:description, else meta description; og:image resolved against finalUrl and dropped unless https on an eligible host. host is finalUrl's host minus one leading www.; og:url and og:site_name are never read. hosts: whether a host may be fetched, checked on the first request and on every redirect. addresses: whether a resolved address may be connected to; both apps connect only to an address that passed, so DNS rebinding is stopped on both. cacheKeys: the persisted key is web:<key>. lookups: a whole lookup against a scripted server, including the home-page fallback for 401, 403, 404 and a 2xx page with no card. Behavior parity, not a computation with an external reference.", "limits": { - "maxBodyBytes": 524288, + "maxBodyBytes": 1048576, "maxImageBytes": 2097152, "maxRedirects": 3, "timeoutSeconds": 5, @@ -521,5 +521,806 @@ "public": false, "note": "reserved 240.0.0.0/4" } + ], + "lookups": [ + { + "name": "fallback-401", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 401 + }, + { + "url": "https://example.com/", + "status": 200, + "html": "<!doctype html><html><head><meta property=\"og:title\" content=\"Home\"><meta property=\"og:description\" content=\"Site\"><meta property=\"og:image\" content=\"https://cdn.example.com/h.png\"></head><body><p>body</p></body></html>" + } + ], + "fetches": [ + "https://example.com/file/k", + "https://example.com/" + ], + "result": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + }, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + }, + { + "key": "https://example.com/", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + } + ], + "note": "401, 403, 404 and a 2xx page with no title fetch https://<host>/ once and show its card." + }, + { + "name": "fallback-403", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 403 + }, + { + "url": "https://example.com/", + "status": 200, + "html": "<!doctype html><html><head><meta property=\"og:title\" content=\"Home\"><meta property=\"og:description\" content=\"Site\"><meta property=\"og:image\" content=\"https://cdn.example.com/h.png\"></head><body><p>body</p></body></html>" + } + ], + "fetches": [ + "https://example.com/file/k", + "https://example.com/" + ], + "result": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + }, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + }, + { + "key": "https://example.com/", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + } + ], + "note": "A private file. The home page is also cached under its own key." + }, + { + "name": "fallback-404", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 404 + }, + { + "url": "https://example.com/", + "status": 200, + "html": "<!doctype html><html><head><meta property=\"og:title\" content=\"Home\"><meta property=\"og:description\" content=\"Site\"><meta property=\"og:image\" content=\"https://cdn.example.com/h.png\"></head><body><p>body</p></body></html>" + } + ], + "fetches": [ + "https://example.com/file/k", + "https://example.com/" + ], + "result": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + }, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + }, + { + "key": "https://example.com/", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + } + ], + "note": "404 falls back too: private links on some sites answer 404." + }, + { + "name": "fallback-no-title", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 200, + "html": "<!doctype html><html><head><meta property=\"og:description\" content=\"d\"></head><body><p>body</p></body></html>" + }, + { + "url": "https://example.com/", + "status": 200, + "html": "<!doctype html><html><head><meta property=\"og:title\" content=\"Home\"><meta property=\"og:description\" content=\"Site\"><meta property=\"og:image\" content=\"https://cdn.example.com/h.png\"></head><body><p>body</p></body></html>" + } + ], + "fetches": [ + "https://example.com/file/k", + "https://example.com/" + ], + "result": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + }, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + }, + { + "key": "https://example.com/", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + } + ], + "note": "A 2xx page that parses to no card." + }, + { + "name": "no-fallback-410", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 410 + } + ], + "fetches": [ + "https://example.com/file/k" + ], + "result": "none", + "card": null, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "none", + "card": null + } + ], + "note": "Gone for good: no fallback." + }, + { + "name": "no-fallback-429", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 429 + } + ], + "fetches": [ + "https://example.com/file/k" + ], + "result": "none", + "card": null, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "none", + "card": null + } + ], + "note": "Rate limited: a second request would make it worse." + }, + { + "name": "no-fallback-503", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 503 + } + ], + "fetches": [ + "https://example.com/file/k" + ], + "result": "failure", + "card": null, + "cached": [], + "note": "5xx is a failure, never remembered, and never falls back." + }, + { + "name": "no-fallback-non-html", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 200, + "contentType": "application/pdf" + } + ], + "fetches": [ + "https://example.com/file/k" + ], + "result": "none", + "card": null, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "none", + "card": null + } + ], + "note": "A 2xx file that is not HTML is not a page with no title." + }, + { + "name": "root-no-fallback", + "url": "https://example.com/", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/", + "status": 403 + } + ], + "fetches": [ + "https://example.com/" + ], + "result": "none", + "card": null, + "cached": [ + { + "key": "https://example.com/", + "state": "none", + "card": null + } + ], + "note": "Path already / with no query: no second request." + }, + { + "name": "root-with-query-falls-back", + "url": "https://example.com/?ref=x", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/?ref=x", + "status": 403 + }, + { + "url": "https://example.com/", + "status": 200, + "html": "<!doctype html><html><head><meta property=\"og:title\" content=\"Home\"><meta property=\"og:description\" content=\"Site\"><meta property=\"og:image\" content=\"https://cdn.example.com/h.png\"></head><body><p>body</p></body></html>" + } + ], + "fetches": [ + "https://example.com/?ref=x", + "https://example.com/" + ], + "result": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + }, + "cached": [ + { + "key": "https://example.com/?ref=x", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + }, + { + "key": "https://example.com/", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + } + ], + "note": "A query makes it a different page, so the home page is asked." + }, + { + "name": "home-also-403", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 403 + }, + { + "url": "https://example.com/", + "status": 403 + } + ], + "fetches": [ + "https://example.com/file/k", + "https://example.com/" + ], + "result": "none", + "card": null, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "none", + "card": null + }, + { + "key": "https://example.com/", + "state": "none", + "card": null + } + ], + "note": "The home page's own answer is cached, None included." + }, + { + "name": "home-503", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 403 + }, + { + "url": "https://example.com/", + "status": 503 + } + ], + "fetches": [ + "https://example.com/file/k", + "https://example.com/" + ], + "result": "failure", + "card": null, + "cached": [], + "note": "A home page failure fails the lookup; nothing is remembered." + }, + { + "name": "home-cached-none", + "url": "https://example.com/file/k", + "cachedBefore": [ + { + "key": "https://example.com/", + "state": "none", + "card": null + } + ], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 403 + } + ], + "fetches": [ + "https://example.com/file/k" + ], + "result": "none", + "card": null, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "none", + "card": null + } + ], + "note": "A remembered home-page None stops the fallback without a request." + }, + { + "name": "home-cached-resolved", + "url": "https://example.com/file/k", + "cachedBefore": [ + { + "key": "https://example.com/", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + } + ], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 403 + } + ], + "fetches": [ + "https://example.com/file/k" + ], + "result": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + }, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + } + ], + "note": "A remembered home-page card is reused without a request." + }, + { + "name": "fallback-uses-final-host", + "url": "https://short.example.net/x", + "cachedBefore": [], + "responses": [ + { + "url": "https://short.example.net/x", + "status": 301, + "location": "https://www.example.org/file/k" + }, + { + "url": "https://www.example.org/file/k", + "status": 403 + }, + { + "url": "https://www.example.org/", + "status": 200, + "html": "<!doctype html><html><head><meta property=\"og:title\" content=\"Org\"></head><body><p>body</p></body></html>" + } + ], + "fetches": [ + "https://short.example.net/x", + "https://www.example.org/file/k", + "https://www.example.org/" + ], + "result": "resolved", + "card": { + "title": "Org", + "description": null, + "imageUrl": null, + "host": "example.org" + }, + "cached": [ + { + "key": "https://short.example.net/x", + "state": "resolved", + "card": { + "title": "Org", + "description": null, + "imageUrl": null, + "host": "example.org" + } + }, + { + "key": "https://www.example.org/", + "state": "resolved", + "card": { + "title": "Org", + "description": null, + "imageUrl": null, + "host": "example.org" + } + } + ], + "note": "The home page is on the host after redirects; host keeps its www. in the request." + }, + { + "name": "redirects-shared-3", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 301, + "location": "https://example.com/file/k2" + }, + { + "url": "https://example.com/file/k2", + "status": 403 + }, + { + "url": "https://example.com/", + "status": 301, + "location": "https://www.example.com/" + }, + { + "url": "https://www.example.com/", + "status": 301, + "location": "https://www.example.com/home" + }, + { + "url": "https://www.example.com/home", + "status": 200, + "html": "<!doctype html><html><head><meta property=\"og:title\" content=\"Home\"><meta property=\"og:description\" content=\"Site\"><meta property=\"og:image\" content=\"https://cdn.example.com/h.png\"></head><body><p>body</p></body></html>" + } + ], + "fetches": [ + "https://example.com/file/k", + "https://example.com/file/k2", + "https://example.com/", + "https://www.example.com/", + "https://www.example.com/home" + ], + "result": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + }, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + }, + { + "key": "https://example.com/", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + } + ], + "note": "Both fetches share maxRedirects: 1 + 2 = 3 is allowed." + }, + { + "name": "redirects-shared-4", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 301, + "location": "https://example.com/file/k2" + }, + { + "url": "https://example.com/file/k2", + "status": 301, + "location": "https://example.com/file/k3" + }, + { + "url": "https://example.com/file/k3", + "status": 403 + }, + { + "url": "https://example.com/", + "status": 301, + "location": "https://www.example.com/" + }, + { + "url": "https://www.example.com/", + "status": 301, + "location": "https://www.example.com/home" + } + ], + "fetches": [ + "https://example.com/file/k", + "https://example.com/file/k2", + "https://example.com/file/k3", + "https://example.com/", + "https://www.example.com/" + ], + "result": "none", + "card": null, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "none", + "card": null + }, + { + "key": "https://example.com/", + "state": "none", + "card": null + } + ], + "note": "2 + 2 = 4 redirects: the fourth is never followed, so the home page is None." + }, + { + "name": "title-past-512k", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 200, + "html": "<!doctype html><html><head><!--FILL--><meta property=\"og:title\" content=\"Deep\"></head><body></body></html>", + "fillBytes": 716800 + } + ], + "fetches": [ + "https://example.com/file/k" + ], + "result": "resolved", + "card": { + "title": "Deep", + "description": null, + "imageUrl": null, + "host": "example.com" + }, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "resolved", + "card": { + "title": "Deep", + "description": null, + "imageUrl": null, + "host": "example.com" + } + } + ], + "note": "A head that runs past 512 KiB is read up to maxBodyBytes." + }, + { + "name": "title-past-cap-falls-back", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 200, + "html": "<!doctype html><html><head><!--FILL--><meta property=\"og:title\" content=\"Deep\"></head><body></body></html>", + "fillBytes": 1126400 + }, + { + "url": "https://example.com/", + "status": 200, + "html": "<!doctype html><html><head><meta property=\"og:title\" content=\"Home\"><meta property=\"og:description\" content=\"Site\"><meta property=\"og:image\" content=\"https://cdn.example.com/h.png\"></head><body><p>body</p></body></html>" + } + ], + "fetches": [ + "https://example.com/file/k", + "https://example.com/" + ], + "result": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + }, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + }, + { + "key": "https://example.com/", + "state": "resolved", + "card": { + "title": "Home", + "description": "Site", + "imageUrl": "https://cdn.example.com/h.png", + "host": "example.com" + } + } + ], + "note": "Past maxBodyBytes the title is never read, so the page has no title and falls back." + }, + { + "name": "stops-after-head", + "url": "https://example.com/file/k", + "cachedBefore": [], + "responses": [ + { + "url": "https://example.com/file/k", + "status": 200, + "html": "<!doctype html><html><head><meta property=\"og:title\" content=\"Short\"></head><body><!--FILL--></body></html>", + "fillBytes": 2097152 + } + ], + "fetches": [ + "https://example.com/file/k" + ], + "result": "resolved", + "card": { + "title": "Short", + "description": null, + "imageUrl": null, + "host": "example.com" + }, + "cached": [ + { + "key": "https://example.com/file/k", + "state": "resolved", + "card": { + "title": "Short", + "description": null, + "imageUrl": null, + "host": "example.com" + } + } + ], + "note": "Reading stops once </head> is read; a 2 MiB body after it is never downloaded." + } ] } From 5ad97672eaca742316776729cc00849edc1d7118 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Fri, 9 Oct 2026 09:34:51 -0400 Subject: [PATCH 34/39] feat(messenger): stop reading a page once its head has ended --- .../messenger/internal/link/WebLinkLookup.kt | 44 +++++++++++-- .../messenger/internal/link/HeadReaderTest.kt | 62 +++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/HeadReaderTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt index 086ac5f3ba..8bd17fd42c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -18,6 +18,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okio.Buffer +import okio.BufferedSource import java.io.IOException import java.net.Inet4Address import java.net.Inet6Address @@ -170,11 +172,45 @@ internal class WebLinkLookup( it.subtype == "html" || it.subtype == "xhtml+xml" } ?: false - private fun Response.capped(): ByteArray { - val source = body.source() - source.request(WebLinks.MAX_BODY_BYTES.toLong()) - return source.buffer.readByteArray(minOf(source.buffer.size, WebLinks.MAX_BODY_BYTES.toLong())) + private fun Response.capped(): ByteArray = body.source().readHead(WebLinks.MAX_BODY_BYTES) +} + +/** + * Reads up to [cap] bytes of a page, stopping early once the head has ended: the first `</head` or + * `<body`, either case, as [WebPageParser] finds it. A marker split across two reads is still seen, + * because each scan restarts a marker's length before the new bytes. What follows is never pulled + * from the connection. Reads [chunk] bytes at a time. + */ +internal fun BufferedSource.readHead(cap: Int, chunk: Long = 8192): ByteArray { + val out = Buffer() + while (out.size < cap) { + val scanFrom = maxOf(0L, out.size - (HEAD_END_LONGEST - 1)) + if (read(out, minOf(chunk, cap - out.size)) < 0) break + if (out.hasHeadEnd(scanFrom)) break + } + return out.readByteArray() +} + +private const val HEAD_END_LONGEST = 6L + +private fun Buffer.hasHeadEnd(from: Long): Boolean { + var i = from + while (i < size) { + if (this[i] == '<'.code.toByte()) { + if (matchesAt(i, "</head") || matchesAt(i, "<body")) return true + } + i++ + } + return false +} + +private fun Buffer.matchesAt(at: Long, marker: String): Boolean { + if (at + marker.length > size) return false + for (k in marker.indices) { + val b = this[at + k].toInt().toChar().lowercaseChar() + if (b != marker[k]) return false } + return true } private suspend fun Call.await(): Response = suspendCancellableCoroutine { cont -> diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/HeadReaderTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/HeadReaderTest.kt new file mode 100644 index 0000000000..984af7c0eb --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/HeadReaderTest.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.messenger.internal.link + +import okio.Buffer +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The body reader stops once the head has ended, and reads to the cap when it never does. */ +class HeadReaderTest { + + private val filler = "z".repeat(5000) + + private fun source(text: String) = Buffer().writeUtf8(text) + + @Test + fun `reading stops after the head closes and the rest is left unread`() { + val src = source("<html><head><title>T$filler") + val read = src.readHead(cap = 1 shl 20, chunk = 16) + assertTrue(String(read).contains(" 4000, "bytes after the head must stay unread, left ${src.size}") + } + + @Test + fun `an opening body tag also ends the head, in any case`() { + val src = source("T$filler") + val read = src.readHead(cap = 1 shl 20, chunk = 16) + assertTrue(String(read).contains(" 4000, "left ${src.size}") + } + + @Test + fun `an upper case closing head tag ends the head`() { + val src = source("T$filler") + src.readHead(cap = 1 shl 20, chunk = 16) + assertTrue(src.size > 4000, "left ${src.size}") + } + + @Test + fun `a marker split across two reads is still seen`() { + for (chunk in 1L..9L) for (pad in 0..8) { + val src = source("a".repeat(pad) + "" + filler) + src.readHead(cap = 1 shl 20, chunk = chunk) + assertTrue(src.size > 4000, "chunk $chunk pad $pad: left ${src.size}") + val body = source("a".repeat(pad) + "" + filler) + body.readHead(cap = 1 shl 20, chunk = chunk) + assertTrue(body.size > 4000, "body chunk $chunk pad $pad: left ${body.size}") + } + } + + @Test + fun `without either marker it reads to the cap`() { + val src = source("" + filler) + val read = src.readHead(cap = 1000, chunk = 64) + assertEquals(1000, read.size) + } + + @Test + fun `a short body without a marker is returned whole`() { + val read = source("T").readHead(cap = 1000) + assertEquals("T", String(read)) + } +} From 7abc92f8d91726ba1e95c3f0d6540619fbf03269 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 09:37:34 -0400 Subject: [PATCH 35/39] feat(messenger): fall back to the home page when a link has no card of its own --- .../internal/link/LinkCardMemoryModule.kt | 2 + .../messenger/internal/link/WebLinkLookup.kt | 90 +++++++-- .../internal/link/LookupVectorTest.kt | 191 ++++++++++++++++++ 3 files changed, 263 insertions(+), 20 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt index 58f96f3f8c..2989f2b8b3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt @@ -95,10 +95,12 @@ internal abstract class LinkCardMemoryModule { @WebPreview client: OkHttpClient, flags: FeatureFlagController, dispatchers: DispatcherProvider, + memory: LinkCardMemory, ): WebLinkLookup = WebLinkLookup( client = client, enabled = { flags.get(FeatureFlag.WebLinkPreviews) }, dispatchers = dispatchers, + memory = memory, ) @Provides diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt index 8bd17fd42c..82d44f5f20 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -117,6 +117,7 @@ internal class WebLinkLookup( private val enabled: suspend () -> Boolean, private val dispatchers: DispatcherProvider, private val deadline: Duration = WebLinks.LOOKUP_DEADLINE, + private val memory: LinkCardMemory? = null, ) { private val inFlight = Semaphore(WebLinks.MAX_CONCURRENT) @@ -136,29 +137,78 @@ internal class WebLinkLookup( private suspend fun fetch(url: String): Result = withContext(dispatchers.IO) { if (!enabled()) return@withContext Result.failure(IllegalStateException("web previews off")) runCatching { - var current = url.toHttpUrl() - // The first request plus MAX_REDIRECTS redirects. A redirect answering the last one is None. - repeat(WebLinks.MAX_REDIRECTS + 1) { - // Port 443 only, on every hop (parity decision D12). - if (!current.isFetchable()) return@runCatching LinkCard.Web.State.None - client.newCall(request(current)).await().use { response -> - response.requireConsistentLength() - when { - response.isRedirect -> { - current = response.redirectTarget(current) ?: return@runCatching LinkCard.Web.State.None - } - response.code >= 500 -> throw IOException("HTTP ${response.code}") - !response.isSuccessful -> return@runCatching LinkCard.Web.State.None - !response.isHtml() -> return@runCatching LinkCard.Web.State.None - // The caps count bytes read, so a compressed body is not read at all. - response.isEncoded() -> return@runCatching LinkCard.Web.State.None - else -> return@runCatching WebPageParser.parse(response.capped(), current.toString()) - ?: LinkCard.Web.State.None + // One budget of redirects for the link and, if it is needed, the home page. + val hops = Hops() + when (val first = fetchPage(url.toHttpUrl(), hops)) { + is Page.Answer -> first.state + Page.OutOfRedirects -> LinkCard.Web.State.None + is Page.Retry -> homePage(first.finalUrl, hops) + } + }.onFailure { if (it is CancellationException) throw it } + } + + /** + * The home page of [finalUrl]'s host, for a link that is private or has no card of its own (parity + * decisions P23, P24). A remembered answer for it is used without a request. Its answer is + * remembered under its own key too, None included. Running out of redirects there is the one + * answer that is not remembered for the home page: the link gets None and the home key nothing. + */ + private suspend fun homePage(finalUrl: HttpUrl, hops: Hops): LinkCard.Web.State { + if (finalUrl.encodedPath == "/" && finalUrl.query == null) return LinkCard.Web.State.None + val home = finalUrl.newBuilder().encodedPath("/").query(null).fragment(null).build() + val key = WebLinks.cacheKey(home.toString()) + key?.let { k -> memory?.webs?.get(k)?.let { return it } } + val state = when (val page = fetchPage(home, hops)) { + is Page.Answer -> page.state + is Page.Retry -> LinkCard.Web.State.None + Page.OutOfRedirects -> return LinkCard.Web.State.None + } + if (key != null) memory?.putWeb(key, state) + return state + } + + private class Hops { + var redirects = 0 + } + + private sealed interface Page { + /** The page's own answer, None included. */ + data class Answer(val state: LinkCard.Web.State) : Page + + /** The page is private or has no card: 401, 403, 404, or a 2xx page without a title. [finalUrl] is where it ended. */ + data class Retry(val finalUrl: HttpUrl) : Page + + data object OutOfRedirects : Page + } + + /** Follows redirects by hand, spending [hops], and checks every hop. 5xx throws. */ + private suspend fun fetchPage(start: HttpUrl, hops: Hops): Page { + var current = start + while (true) { + // Port 443 only, on every hop (parity decision D12). + if (!current.isFetchable()) return Page.Answer(LinkCard.Web.State.None) + val next: HttpUrl = client.newCall(request(current)).await().use { response -> + response.requireConsistentLength() + when { + response.isRedirect -> { + val target = response.redirectTarget(current) + ?: return Page.Answer(LinkCard.Web.State.None) + // The first request plus MAX_REDIRECTS redirects, home page included. + if (++hops.redirects > WebLinks.MAX_REDIRECTS) return Page.OutOfRedirects + target } + response.code >= 500 -> throw IOException("HTTP ${response.code}") + response.code == 401 || response.code == 403 || response.code == 404 -> return Page.Retry(current) + !response.isSuccessful -> return Page.Answer(LinkCard.Web.State.None) + !response.isHtml() -> return Page.Answer(LinkCard.Web.State.None) + // The caps count bytes read, so a compressed body is not read at all. + response.isEncoded() -> return Page.Answer(LinkCard.Web.State.None) + else -> return WebPageParser.parse(response.capped(), current.toString()) + ?.let { Page.Answer(it) } ?: Page.Retry(current) } } - LinkCard.Web.State.None - }.onFailure { if (it is CancellationException) throw it } + current = next + } } // Set on the request, so OkHttp's bridge sees Accept-Encoding and leaves the body encoded. diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt new file mode 100644 index 0000000000..12be4d9dd3 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt @@ -0,0 +1,191 @@ +package com.flipcash.app.messenger.internal.link + +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.shared.chat.models.LinkCard +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import okio.BufferedSource +import okio.ForwardingSource +import okio.buffer +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.fail + +/** + * Runs every `lookups` vector of `link_metadata.json`: the scripted responses by URL, the requests + * made in order, the result, the card, and the keys the lookup and the resolver wrote. All failing + * vectors are reported together. The fixture is a synced copy, so a disagreement is fixed in the + * canonical file, never here -- with the one override named in [pendingFixtureChange]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class LookupVectorTest { + + private val realIo = object : DispatcherProvider { + override val Default: CoroutineDispatcher = Dispatchers.Default + override val Main: CoroutineDispatcher = Dispatchers.Default + override val IO: CoroutineDispatcher = Dispatchers.IO + } + + private fun fixture(): JSONObject = JSONObject( + javaClass.classLoader!!.getResourceAsStream("link_metadata.json")!! + .bufferedReader().use { it.readText() } + ) + + /** What the memory was asked to hold since the vector began, by key. Seeding is not recorded. */ + private class RecordingMemory : LinkCardMemory() { + val puts = LinkedHashMap() + var recording = false + override fun putWeb(key: String, state: LinkCard.Web.State) { + if (recording) puts[key] = state + super.putWeb(key, state) + } + } + + /** + * Pending fixture change, orchestrator #44: "redirects-shared-4" lists the home key as cached + * None. Our proposal is that running out of redirects during the fallback remembers None for the + * original key only. Drop this override once the fixture says the same. + */ + private fun pendingFixtureChange(name: String, cached: Map) = + if (name == "redirects-shared-4") cached.filterKeys { it != "https://example.com/" } else cached + + private fun JSONArray.objects() = (0 until length()).map { getJSONObject(it) } + + private fun state(o: JSONObject?): LinkCard.Web.State.Resolved? = o?.let { + LinkCard.Web.State.Resolved( + it.getString("title"), + it.optString("description").takeUnless { _ -> it.isNull("description") || !it.has("description") }, + it.optString("imageUrl").takeUnless { _ -> it.isNull("imageUrl") || !it.has("imageUrl") }, + it.getString("host"), + ) + } + + private fun stored(row: JSONObject): LinkCard.Web.State = when (row.getString("state")) { + "resolved" -> state(row.getJSONObject("card"))!! + else -> LinkCard.Web.State.None + } + + private fun htmlOf(r: JSONObject): ByteArray { + val html = r.optString("html", "") + if (!r.has("fillBytes")) return html.toByteArray() + val marker = "" + val pad = r.getInt("fillBytes") - (html.length - marker.length) + check(pad >= 0) { "fillBytes smaller than the page" } + return html.replace(marker, "x".repeat(pad)).toByteArray().also { check(it.size == r.getInt("fillBytes")) } + } + + private class Counting( + private val type: String?, + private val bytes: ByteArray, + private val pulled: AtomicLong, + ) : ResponseBody() { + override fun contentType() = type?.toMediaType() + override fun contentLength() = -1L + override fun source(): BufferedSource = + object : ForwardingSource(Buffer().write(bytes)) { + override fun read(sink: Buffer, byteCount: Long): Long = + super.read(sink, byteCount).also { if (it > 0) pulled.addAndGet(it) } + }.buffer() + } + + private fun runVector(v: JSONObject): List { + val name = v.getString("name") + val problems = mutableListOf() + val responses = v.getJSONArray("responses").objects().associateBy { it.getString("url") } + val seen = CopyOnWriteArrayList() + val pulled = AtomicLong() + val client = webPreviewClient().newBuilder().addInterceptor(Interceptor { chain -> + val req: Request = chain.request() + val url = req.url.toString() + seen += url + val r = responses[url] ?: throw IOException("unscripted request $url") + val builder = Response.Builder().request(req).protocol(Protocol.HTTP_1_1) + .code(r.getInt("status")).message("m") + if (r.has("location")) builder.header("Location", r.getString("location")) + val type = if (r.has("contentType")) r.getString("contentType") + else if (r.has("html")) "text/html; charset=utf-8" else null + builder.body(Counting(type, htmlOf(r), pulled)).build() + }).build() + + val memory = RecordingMemory() + v.getJSONArray("cachedBefore").objects().forEach { memory.putWeb(it.getString("key"), stored(it)) } + memory.recording = true + + val lookup = WebLinkLookup(client, { true }, realIo, memory = memory) + var lookupResult: Result? = null + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val url = v.getString("url") + val resolved = try { + runBlocking { + LinkCardResolver( + scope = scope, + giftCard = { Result.failure(IllegalStateException("unused")) }, + tokenMetadata = { Result.failure(IllegalStateException("unused")) }, + group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, + web = { lookup(it).also { r -> lookupResult = r } }, + memory = memory, + ).resolve(LinkCard.Web(url, 0, url.length)) as LinkCard.Web + } + } finally { + scope.cancel() + } + + val expectedFetches = v.getJSONArray("fetches").let { a -> (0 until a.length()).map { a.getString(it) } } + if (seen != expectedFetches) problems += "fetches $seen, expected $expectedFetches" + + val result = lookupResult + when (v.getString("result")) { + "failure" -> if (result?.isFailure != true) problems += "expected a failure, got $result" + "none" -> if (result?.getOrNull() != LinkCard.Web.State.None) problems += "expected None, got $result" + "resolved" -> { + val card = state(v.getJSONObject("card")) + if (result?.getOrNull() != card) problems += "expected $card, got $result" + if (resolved.state != card) problems += "resolver drew ${resolved.state}, expected $card" + } + } + if (resolved.url != url) problems += "the card opens ${resolved.url}, expected the original $url" + + val expectedCached = pendingFixtureChange( + name, + v.getJSONArray("cached").objects().associate { it.getString("key") to stored(it) }, + ) + if (memory.puts != expectedCached) problems += "cached ${memory.puts}, expected $expectedCached" + + if (name == "stops-after-head" && pulled.get() > 64 * 1024) { + problems += "pulled ${pulled.get()} bytes, the body after the head must not be downloaded" + } + return problems.map { "$name: $it" } + } + + @Test + fun `every lookup vector in the fixture holds`() { + val vectors = fixture().getJSONArray("lookups").objects() + check(vectors.size == 20) { "expected 20 lookup vectors, found ${vectors.size}" } + val failures = vectors.flatMap { v -> + runCatching { runVector(v) }.getOrElse { listOf("${v.getString("name")}: threw $it") } + } + if (failures.isNotEmpty()) { + fail("${failures.map { it.substringBefore(':') }.distinct().size} vector(s) failed:\n" + failures.joinToString("\n")) + } + } +} From 475e4b7c13ce38423db6bff748c90986b9b5f1ce Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 10:14:57 -0400 Subject: [PATCH 36/39] test(messenger): sync link_metadata fixture from orchestrator #44 and 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. --- .../messenger/internal/link/LookupVectorTest.kt | 15 ++------------- .../src/test/resources/link_metadata.json | 7 +------ 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt index 12be4d9dd3..6fc337017a 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt @@ -33,7 +33,7 @@ import kotlin.test.fail * Runs every `lookups` vector of `link_metadata.json`: the scripted responses by URL, the requests * made in order, the result, the card, and the keys the lookup and the resolver wrote. All failing * vectors are reported together. The fixture is a synced copy, so a disagreement is fixed in the - * canonical file, never here -- with the one override named in [pendingFixtureChange]. + * canonical file, never here. */ @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) @@ -60,14 +60,6 @@ class LookupVectorTest { } } - /** - * Pending fixture change, orchestrator #44: "redirects-shared-4" lists the home key as cached - * None. Our proposal is that running out of redirects during the fallback remembers None for the - * original key only. Drop this override once the fixture says the same. - */ - private fun pendingFixtureChange(name: String, cached: Map) = - if (name == "redirects-shared-4") cached.filterKeys { it != "https://example.com/" } else cached - private fun JSONArray.objects() = (0 until length()).map { getJSONObject(it) } private fun state(o: JSONObject?): LinkCard.Web.State.Resolved? = o?.let { @@ -165,10 +157,7 @@ class LookupVectorTest { } if (resolved.url != url) problems += "the card opens ${resolved.url}, expected the original $url" - val expectedCached = pendingFixtureChange( - name, - v.getJSONArray("cached").objects().associate { it.getString("key") to stored(it) }, - ) + val expectedCached = v.getJSONArray("cached").objects().associate { it.getString("key") to stored(it) } if (memory.puts != expectedCached) problems += "cached ${memory.puts}, expected $expectedCached" if (name == "stops-after-head" && pulled.get() > 64 * 1024) { diff --git a/apps/flipcash/features/messenger/src/test/resources/link_metadata.json b/apps/flipcash/features/messenger/src/test/resources/link_metadata.json index a80051524b..6658d25390 100644 --- a/apps/flipcash/features/messenger/src/test/resources/link_metadata.json +++ b/apps/flipcash/features/messenger/src/test/resources/link_metadata.json @@ -1189,14 +1189,9 @@ "key": "https://example.com/file/k", "state": "none", "card": null - }, - { - "key": "https://example.com/", - "state": "none", - "card": null } ], - "note": "2 + 2 = 4 redirects: the fourth is never followed, so the home page is None." + "note": "2 + 2 = 4 redirects: the fourth is never followed, so the link is None. The home key stores nothing: the budget was spent by the original link, and only a home answer reached within its own rules is stored." }, { "name": "title-past-512k", From 9fdbff8585807968ccfaca078206b71d669c807b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 11:29:16 -0400 Subject: [PATCH 37/39] feat(messenger): give flipcash.com pages a web card unless the link carries 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= 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). --- .../internal/link/LinkCardClassifier.kt | 26 +++- .../internal/link/LinkCardClassifierTest.kt | 30 ++++- .../src/test/resources/link_detection.json | 123 ++++++++++++++++-- .../src/test/resources/link_detection.json | 123 ++++++++++++++++-- 4 files changed, 280 insertions(+), 22 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt index 679386767f..161ce2a348 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt @@ -9,6 +9,7 @@ import com.flipcash.app.router.Router import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.ui.DetectedUrl import dev.theolm.rinku.DeepLink +import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import javax.inject.Inject @@ -53,18 +54,31 @@ internal class LinkCardClassifier @Inject constructor( links.firstNotNullOfOrNull { classify(it) } ?: links.firstNotNullOfOrNull { web(it) } /** - * An outside https link. A card host never falls through to here, whatever its path, and that - * includes a jump wrapper around an outside target. + * An outside https link, or a page of the website (parity decision P25). Every other card host + * never falls through to here, whatever its path, and that includes a jump wrapper around an + * outside target. */ private fun web(link: DetectedUrl): LinkCard.Web? { if (WebLinks.hasEscapedHost(link.url)) return null val url = link.url.toHttpUrlOrNull() ?: return null // An explicit port other than 443 gets no card (parity decision D12). if (url.scheme != "https" || url.port != 443) return null - if (url.host in CARD_HOSTS || !WebLinks.isEligibleHost(url.host)) return null + if (url.host in CARD_HOSTS && !url.isWebsitePage()) return null + if (!WebLinks.isEligibleHost(url.host)) return null return LinkCard.Web(url = link.url, start = link.start, end = link.end) } + /** + * A link on the website's hosts that is not a Flipcash card (P25). The router matches + * `/login`, `/c`, `/cash` and `/verify` on any host, so `flipcash.com/login/e=` would + * otherwise be fetched with the seed in its path; those segments never fall through, and + * neither does a link with a fragment, where entropy travels. + */ + private fun HttpUrl.isWebsitePage(): Boolean = + host in WEBSITE_HOSTS && + fragment == null && + pathSegments.first().lowercase() !in SECRET_SEGMENTS + private fun classify(link: DetectedUrl): LinkCard? { val target = unwrapJumpTarget(link.url) ?: link.url val host = runCatching { target.toUri().host }.getOrNull() ?: return null @@ -159,6 +173,12 @@ internal class LinkCardClassifier @Inject constructor( private const val GROUP_INVITE_SEGMENTS = 2 private const val JUMP_HOST = "jump.flipcash.com" + + /** Hosts whose non-card links are website pages (P25). */ + private val WEBSITE_HOSTS = setOf("flipcash.com", "www.flipcash.com") + + /** First path segments that carry a seed, entropy or a verification code (P25). */ + private val SECRET_SEGMENTS = setOf("login", "verify", "c", "cash") private const val JUMP_SOURCE_PARAM = "source=" /** diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt index 50847a941d..c45475aa69 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt @@ -17,6 +17,7 @@ import org.robolectric.annotation.Config import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -272,14 +273,41 @@ class LinkCardClassifierTest { assertNull(cardFor("https://example.com./")) } + /** P25: the website's own pages are web cards, never a person card. */ @Test - fun `a website page stays a link`() { + fun `a website page is a web card`() { listOf( "https://flipcash.com/download", "https://flipcash.com/Privacy", "https://flipcash.com/terms", "https://flipcash.com/currencycreator", "https://flipcash.com/api", + "https://www.flipcash.com/about", + ).forEach { assertIs(cardFor(it), it) } + } + + /** P25: a seed, entropy or verification code is never fetched as a website page. */ + @Test + fun `a website link carrying a secret is not a card`() { + listOf( + "https://flipcash.com/login/e=KNi8pQr1n5hRU65vKJGge3", + "https://flipcash.com/LOGIN/e=KNi8pQr1n5hRU65vKJGge3", + "https://flipcash.com/login", + "https://www.flipcash.com/verify?email=a%40b.com&code=123456", + "https://flipcash.com/c/other", + "https://flipcash.com/cash/other", + "https://flipcash.com/#/e=KNi8pQr1n5hRU65vKJGge3", + "https://flipcash.com/download#", + ).forEach { assertNull(cardFor(it), it) } + } + + /** P25 is the website only: the app hosts' unknown paths stay links. */ + @Test + fun `an app host path that does not classify is not a card`() { + listOf( + "https://app.flipcash.com/download", + "https://send.flipcash.com/", + "https://jump.flipcash.com/", ).forEach { assertNull(cardFor(it), it) } } diff --git a/apps/flipcash/features/messenger/src/test/resources/link_detection.json b/apps/flipcash/features/messenger/src/test/resources/link_detection.json index 4d0e2413ca..e728d29538 100644 --- a/apps/flipcash/features/messenger/src/test/resources/link_detection.json +++ b/apps/flipcash/features/messenger/src/test/resources/link_detection.json @@ -1,6 +1,6 @@ { "algorithm": "link-detection", - "note": "Chat link spans and card eligibility. Offsets are UTF-16 code units, half-open. `url` is the resolved URL (https:// prepended when the match had no scheme). `card` is null or the first card-eligible Flipcash link in the message, with its jump wrapper already unwrapped. `card` is null, the first Flipcash card in the message, or else the first https span whose host is outside cardHosts and eligible (see link_metadata.json hosts), with kind `web`. A card-host span that does not classify never becomes a web card. A match whose following character is non-ASCII is rejected. Behavior parity, not a computation with an external reference.", + "note": "Chat link spans and card eligibility. Offsets are UTF-16 code units, half-open. `url` is the resolved URL (https:// prepended when the match had no scheme). `card` is null or the first card-eligible Flipcash link in the message, with its jump wrapper already unwrapped. `card` is null, the first Flipcash card in the message, or else the first https span whose host is outside cardHosts and eligible (see link_metadata.json hosts), with kind `web`. A card-host span that does not classify never becomes a web card, except on flipcash.com and www.flipcash.com (P25): there it is a web card unless its first path segment is login, verify, c or cash, or it has a fragment. A match whose following character is non-ASCII is rejected. Behavior parity, not a computation with an external reference.", "cardHosts": [ "app.flipcash.com", "send.flipcash.com", @@ -72,8 +72,11 @@ "url": "https://flipcash.com/download" } ], - "card": null, - "note": "No scheme: still a link, resolved with https://. A reserved path, so no card." + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, + "note": "No scheme: still a link, resolved with https://. A marketing-host page, so a web card (P25)." }, { "name": "app-host-cash-link", @@ -248,7 +251,10 @@ "url": "https://flipcash.com/download" } ], - "card": null, + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, "note": "A run of dropped characters is dropped whole: . , ; ! and a double quote." }, { @@ -294,7 +300,10 @@ "url": "https://flipcash.com/download?" } ], - "card": null, + "card": { + "kind": "web", + "url": "https://flipcash.com/download?" + }, "note": "? and : stay in the link, because NSDataDetector keeps them (it reads a trailing ? as an empty query). Pinned so neither side drops more than the other." }, { @@ -307,7 +316,10 @@ "url": "https://flipcash.com/download" } ], - "card": null, + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, "note": "A single quote is dropped only when one opened the link. Unopened, it stays, as NSDataDetector keeps it." }, { @@ -336,7 +348,10 @@ "url": "https://flipcash.com/download" } ], - "card": null, + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, "note": "The same for _." }, { @@ -349,7 +364,10 @@ "url": "https://flipcash.com/download" } ], - "card": null, + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, "note": "The same for ~." }, { @@ -514,6 +532,19 @@ }, { "name": "flipcash-host-never-falls-through", + "text": "https://app.flipcash.com/download", + "spans": [ + { + "start": 0, + "end": 33, + "url": "https://app.flipcash.com/download" + } + ], + "card": null, + "note": "An app., send. or jump. link that does not classify is never a web card." + }, + { + "name": "marketing-host-falls-through", "text": "https://flipcash.com/download", "spans": [ { @@ -522,8 +553,82 @@ "url": "https://flipcash.com/download" } ], + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, + "note": "P25: flipcash.com and www.flipcash.com pages that do not classify are web cards." + }, + { + "name": "www-marketing-root", + "text": "https://www.flipcash.com/", + "spans": [ + { + "start": 0, + "end": 25, + "url": "https://www.flipcash.com/" + } + ], + "card": { + "kind": "web", + "url": "https://www.flipcash.com/" + }, + "note": "P25: the marketing root is a web card." + }, + { + "name": "bare-marketing-root", + "text": "flipcash.com", + "spans": [ + { + "start": 0, + "end": 12, + "url": "https://flipcash.com" + } + ], + "card": { + "kind": "web", + "url": "https://flipcash.com" + }, + "note": "P25: a bare domain gets https:// and becomes a web card." + }, + { + "name": "apex-login-path-never-web", + "text": "https://flipcash.com/login/e=KNi8pQr1n5hRU65vKJGge3", + "spans": [ + { + "start": 0, + "end": 51, + "url": "https://flipcash.com/login/e=KNi8pQr1n5hRU65vKJGge3" + } + ], + "card": null, + "note": "P25: a first path segment of login, verify, c or cash is never fetched; the path can carry the account seed." + }, + { + "name": "www-verify-never-web", + "text": "https://www.flipcash.com/verify?email=a%40b.com&code=123456", + "spans": [ + { + "start": 0, + "end": 59, + "url": "https://www.flipcash.com/verify?email=a%40b.com&code=123456" + } + ], + "card": null, + "note": "P25: a verification code in the query is never fetched." + }, + { + "name": "apex-fragment-never-web", + "text": "https://flipcash.com/#/e=KNi8pQr1n5hRU65vKJGge3", + "spans": [ + { + "start": 0, + "end": 47, + "url": "https://flipcash.com/#/e=KNi8pQr1n5hRU65vKJGge3" + } + ], "card": null, - "note": "A card host that does not classify is never a web card." + "note": "P25: a marketing-host link with a fragment is never a web card." }, { "name": "login-then-web", diff --git a/apps/flipcash/shared/chat-ui/src/test/resources/link_detection.json b/apps/flipcash/shared/chat-ui/src/test/resources/link_detection.json index 4d0e2413ca..e728d29538 100644 --- a/apps/flipcash/shared/chat-ui/src/test/resources/link_detection.json +++ b/apps/flipcash/shared/chat-ui/src/test/resources/link_detection.json @@ -1,6 +1,6 @@ { "algorithm": "link-detection", - "note": "Chat link spans and card eligibility. Offsets are UTF-16 code units, half-open. `url` is the resolved URL (https:// prepended when the match had no scheme). `card` is null or the first card-eligible Flipcash link in the message, with its jump wrapper already unwrapped. `card` is null, the first Flipcash card in the message, or else the first https span whose host is outside cardHosts and eligible (see link_metadata.json hosts), with kind `web`. A card-host span that does not classify never becomes a web card. A match whose following character is non-ASCII is rejected. Behavior parity, not a computation with an external reference.", + "note": "Chat link spans and card eligibility. Offsets are UTF-16 code units, half-open. `url` is the resolved URL (https:// prepended when the match had no scheme). `card` is null or the first card-eligible Flipcash link in the message, with its jump wrapper already unwrapped. `card` is null, the first Flipcash card in the message, or else the first https span whose host is outside cardHosts and eligible (see link_metadata.json hosts), with kind `web`. A card-host span that does not classify never becomes a web card, except on flipcash.com and www.flipcash.com (P25): there it is a web card unless its first path segment is login, verify, c or cash, or it has a fragment. A match whose following character is non-ASCII is rejected. Behavior parity, not a computation with an external reference.", "cardHosts": [ "app.flipcash.com", "send.flipcash.com", @@ -72,8 +72,11 @@ "url": "https://flipcash.com/download" } ], - "card": null, - "note": "No scheme: still a link, resolved with https://. A reserved path, so no card." + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, + "note": "No scheme: still a link, resolved with https://. A marketing-host page, so a web card (P25)." }, { "name": "app-host-cash-link", @@ -248,7 +251,10 @@ "url": "https://flipcash.com/download" } ], - "card": null, + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, "note": "A run of dropped characters is dropped whole: . , ; ! and a double quote." }, { @@ -294,7 +300,10 @@ "url": "https://flipcash.com/download?" } ], - "card": null, + "card": { + "kind": "web", + "url": "https://flipcash.com/download?" + }, "note": "? and : stay in the link, because NSDataDetector keeps them (it reads a trailing ? as an empty query). Pinned so neither side drops more than the other." }, { @@ -307,7 +316,10 @@ "url": "https://flipcash.com/download" } ], - "card": null, + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, "note": "A single quote is dropped only when one opened the link. Unopened, it stays, as NSDataDetector keeps it." }, { @@ -336,7 +348,10 @@ "url": "https://flipcash.com/download" } ], - "card": null, + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, "note": "The same for _." }, { @@ -349,7 +364,10 @@ "url": "https://flipcash.com/download" } ], - "card": null, + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, "note": "The same for ~." }, { @@ -514,6 +532,19 @@ }, { "name": "flipcash-host-never-falls-through", + "text": "https://app.flipcash.com/download", + "spans": [ + { + "start": 0, + "end": 33, + "url": "https://app.flipcash.com/download" + } + ], + "card": null, + "note": "An app., send. or jump. link that does not classify is never a web card." + }, + { + "name": "marketing-host-falls-through", "text": "https://flipcash.com/download", "spans": [ { @@ -522,8 +553,82 @@ "url": "https://flipcash.com/download" } ], + "card": { + "kind": "web", + "url": "https://flipcash.com/download" + }, + "note": "P25: flipcash.com and www.flipcash.com pages that do not classify are web cards." + }, + { + "name": "www-marketing-root", + "text": "https://www.flipcash.com/", + "spans": [ + { + "start": 0, + "end": 25, + "url": "https://www.flipcash.com/" + } + ], + "card": { + "kind": "web", + "url": "https://www.flipcash.com/" + }, + "note": "P25: the marketing root is a web card." + }, + { + "name": "bare-marketing-root", + "text": "flipcash.com", + "spans": [ + { + "start": 0, + "end": 12, + "url": "https://flipcash.com" + } + ], + "card": { + "kind": "web", + "url": "https://flipcash.com" + }, + "note": "P25: a bare domain gets https:// and becomes a web card." + }, + { + "name": "apex-login-path-never-web", + "text": "https://flipcash.com/login/e=KNi8pQr1n5hRU65vKJGge3", + "spans": [ + { + "start": 0, + "end": 51, + "url": "https://flipcash.com/login/e=KNi8pQr1n5hRU65vKJGge3" + } + ], + "card": null, + "note": "P25: a first path segment of login, verify, c or cash is never fetched; the path can carry the account seed." + }, + { + "name": "www-verify-never-web", + "text": "https://www.flipcash.com/verify?email=a%40b.com&code=123456", + "spans": [ + { + "start": 0, + "end": 59, + "url": "https://www.flipcash.com/verify?email=a%40b.com&code=123456" + } + ], + "card": null, + "note": "P25: a verification code in the query is never fetched." + }, + { + "name": "apex-fragment-never-web", + "text": "https://flipcash.com/#/e=KNi8pQr1n5hRU65vKJGge3", + "spans": [ + { + "start": 0, + "end": 47, + "url": "https://flipcash.com/#/e=KNi8pQr1n5hRU65vKJGge3" + } + ], "card": null, - "note": "A card host that does not classify is never a web card." + "note": "P25: a marketing-host link with a fragment is never a web card." }, { "name": "login-then-web", From 6455b91bd02377307f80e7c8a08bb9585ffcae82 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 11:46:13 -0400 Subject: [PATCH 38/39] chore(chat-ui): drop the pending-review notes on the preview chip copy --- apps/flipcash/core/src/main/res/values/strings.xml | 2 +- .../src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 788e6f4993..f8acc4d035 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1029,7 +1029,7 @@ Group Chat View - + Show preview · %1$s Group Unavailable diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt index 859f598466..89f8f98d20 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt @@ -309,7 +309,6 @@ private fun PreviewImage(imageUrl: String?) { ) } -// Chip copy is pending UX review, with the iOS string. @Composable private fun ShowPreviewChip(host: String, onClick: () -> Unit, modifier: Modifier = Modifier) { val shape = RoundedCornerShape(percent = 50) From 1f226ad6863077a852ac05b7e95a98a8f7d30814 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 13:13:13 -0400 Subject: [PATCH 39/39] test(chat-ui): pass the invite's resolved state to OpenGroup --- .../test/kotlin/com/flipcash/shared/chat/ui/LinkCardTapTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardTapTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardTapTest.kt index 5fbaa4b1c2..809ad9c12f 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardTapTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardTapTest.kt @@ -178,7 +178,7 @@ class LinkCardTapTest { composeTestRule.mainClock.autoAdvance = false composeTestRule.onNodeWithText("View").performClick() - assertEquals(listOf(ChatAction.OpenGroup(chatId)), actions.toList()) + assertEquals(listOf(ChatAction.OpenGroup(chatId, resolved = true)), actions.toList()) } @Test