diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 8b70f0f..c7c7a4b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1820,6 +1820,7 @@ private fun Bubble( else MaterialTheme.colorScheme.onSurface, ) } + line.link?.let { LinkPreviewCard(it, mine = line.mine) } } } } @@ -2708,6 +2709,7 @@ private fun ColumnScope.MediaQualitySection() { val context = LocalContext.current val settings = remember { MediaSettings(context) } var full by remember { mutableStateOf(settings.fullQuality) } + var previews by remember { mutableStateOf(settings.linkPreviews) } Text("Photos", style = MaterialTheme.typography.titleMedium) @@ -2723,6 +2725,29 @@ private fun ColumnScope.MediaQualitySection() { ) } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Preview links you send", fontSize = 16.sp, modifier = Modifier.weight(1f)) + Switch( + checked = previews, + onCheckedChange = { on -> settings.linkPreviews = on; previews = on }, + modifier = Modifier.testTag("link-previews"), + ) + } + + // Says exactly who learns what. The receiving side is the part people + // assume is leaky and is the part that is not. + Text( + "Link previews are fetched by THIS phone before the message is sent, and " + + "travel inside the encryption — whoever you send to never contacts the " + + "site, so the link cannot tell anyone that they read it. Turning this off " + + "stops this phone fetching anything either.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + // ⚠️ Says what the ceiling is rather than implying there is none. "Full // quality" that silently failed on a large photo would be a worse answer // than the compression it replaced. @@ -2903,3 +2928,52 @@ private fun ColumnScope.ScheduledBanner( ) { Text("Cancel") } } } + +/** + * The card under a message containing a link (spec §6.12). + * + * 🔴 **Drawn entirely from bytes the sender put in the ciphertext.** Nothing + * here fetches: a card each recipient fetched would tell the site the reader's + * IP, that they received a link, and roughly when they opened it — once per + * member on a group message. + */ +@Composable +private fun LinkPreviewCard( + preview: io.flamenet.e2e.Content.LinkPreview, + mine: Boolean, +) { + val onBubble = if (mine) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface + Column( + modifier = Modifier + .padding(top = Space.xs) + .fillMaxWidth() + .testTag("link-preview"), + ) { + // 🔴 **The host, always, and separately from the title.** Title and + // description come from a page the sender did not write, so a card + // claiming one destination while the link points at another is a + // phishing primitive. The host is the part that cannot lie. + Text(preview.host, fontSize = 11.sp, color = onBubble.copy(alpha = 0.7f)) + // ⚠️ Plain text, never markup, and line-limited. Both fields are + // attacker-controlled; they are bounded on the way in as well, so this + // is the second of two limits rather than the only one. + Text( + preview.title, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = onBubble, + maxLines = 2, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + if (preview.desc.isNotEmpty()) { + Text( + preview.desc, + fontSize = 12.sp, + color = onBubble.copy(alpha = 0.7f), + maxLines = 3, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + } + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt index efaf515..ff25b6c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt @@ -24,8 +24,21 @@ class MediaSettings(context: Context) { get() = prefs.getBoolean(FULL_QUALITY, false) set(value) = prefs.edit().putBoolean(FULL_QUALITY, value).apply() + /** + * Generate a preview card when sending a link. + * + * ⚠️ Fetching happens on THIS device before the message goes, so the site + * learns somebody is about to share that URL, from this address. The + * recipient never fetches anything (§6.12) — this end does, and that is + * what the setting is for. + */ + var linkPreviews: Boolean + get() = prefs.getBoolean(LINK_PREVIEWS, true) + set(value) = prefs.edit().putBoolean(LINK_PREVIEWS, value).apply() + companion object { private const val PREFS = "media-settings" private const val FULL_QUALITY = "full_quality" + private const val LINK_PREVIEWS = "link_previews" } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 193b2ba..29fb8bf 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -34,6 +34,9 @@ private const val CALL_PRIVACY_ACKNOWLEDGED = "call.privacy.acknowledged" class MessengerViewModel(app: Application) : AndroidViewModel(app) { + /** Photo quality and link-preview preferences. */ + private val mediaSettings = MediaSettings(app) + sealed interface Phase { /** Reading the vault. Brief, but it decides which screen is first. */ data object Starting : Phase @@ -821,6 +824,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } + /** + * A preview for the first link in [text], when the user wants them. + * + * ⚠️ Runs on the caller's IO context — never the main thread. It is a + * network fetch, and a preview must never be able to delay a send from the + * user's point of view, let alone block the UI. + */ + private fun linkPreviewFor(text: String): io.flamenet.e2e.Content.LinkPreview? { + if (!mediaSettings.linkPreviews) return null + val url = io.flamenet.client.LinkPreviewFetcher.firstUrl(text) ?: return null + return io.flamenet.client.LinkPreviewFetcher.fetch(url) + } + // -- Scheduled send ------------------------------------------------------- /** @@ -1752,7 +1768,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { runCatching { val group = chat.group if (group != null) current.sendGroup(group, text) - else current.send(chat.peerId!!, text) + // §6.12 — fetched HERE, on the sender's device, and carried + // inside the ciphertext. The recipient never touches the URL: a + // card each of them fetched would tell the site who read the + // link and when, once per member of a group. + // + // ⚠️ Best-effort. Every failure path ends as a message with a + // plain URL in it, which is a perfectly good thing to be. + else current.send(chat.peerId!!, text, linkPreviewFor(text)) } } result.onSuccess { sent -> diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index f924ee3..804c919 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -72,6 +72,8 @@ class Session( * field existed — absence of a claim, not a claim of failure. */ val delivery: Delivery? = null, + /** A sender-generated preview of a link in this message (§6.12). */ + val link: Content.LinkPreview? = null, ) /** Where an outbound message got to. */ @@ -102,6 +104,7 @@ class Session( }, delivery = m.optString("delivery").takeIf { it.isNotBlank() } ?.let { runCatching { Delivery.valueOf(it) }.getOrNull() }, + link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) }, ) } } @@ -115,6 +118,7 @@ class Session( author: Long = 0, at: Long = System.currentTimeMillis(), delivery: Delivery? = null, + link: Content.LinkPreview? = null, ) { vault.append(threadId, JSONObject() .put("author", author) @@ -125,7 +129,8 @@ class Session( it.put("stk", JSONObject().put("pack", sticker.pack).put("id", sticker.id)) } } - .also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) }) + .also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) } + .also { if (link != null) it.put("lnk", link.toJson()) }) // 🔴 A reply arriving cancels the reminder, and it is done HERE because // this is the one place every inbound message passes through. Hanging it @@ -273,10 +278,26 @@ class Session( * Recording with the outcome is what makes neither possible. See * [Line.delivered]. */ - fun send(peerUserId: Long, text: String): Conversations.Sent { - val sent = conversations.send(peerUserId, text) + fun send( + peerUserId: Long, + text: String, + /** + * A preview the CALLER already fetched (§6.12). + * + * ⚠️ Passed in rather than fetched here: the fetch needs the user's + * setting, which needs an Android context, and this layer deliberately + * has none. It also keeps a network round trip out of the send path + * for every message that has no link in it. + */ + link: Content.LinkPreview? = null, + ): Conversations.Sent { + // With no preview this is the raw body, byte for byte, exactly as + // before — `Content.text` only builds an envelope when it has to. + val plaintext = Content.text(text, link = link) + val sent = conversations.send(peerUserId, plaintext) val at = System.currentTimeMillis() record(Vault.threadForUser(peerUserId), text, mine = true, at = at, + link = link, delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) // 🔴 Queued, not abandoned. A friend who is simply signed out should // not cost the sender a reminder to try again later. diff --git a/client/src/main/kotlin/io/flamenet/client/LinkPreviewFetcher.kt b/client/src/main/kotlin/io/flamenet/client/LinkPreviewFetcher.kt new file mode 100644 index 0000000..3893f8a --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/LinkPreviewFetcher.kt @@ -0,0 +1,96 @@ +package io.flamenet.client + +import io.flamenet.e2e.Content +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Fetches a preview for a link the user is about to send (spec §6.12). + * + * 🔴 **This runs on the SENDER's device, and the result travels inside the + * ciphertext.** The obvious implementation — each recipient fetching the URL to + * draw a card — is the one that must not be built: it tells the site the + * reader's IP address, that they received a link, and roughly when they opened + * it, once per member on a group message. A messenger that hides the message + * and then has every reader announce its contents to a third party has given + * away what it was protecting. + * + * ⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is + * about to share that URL, from this address, before the message is sent. That + * is what the setting is for. + */ +object LinkPreviewFetcher { + + /** A short timeout: a preview is a decoration and must never delay a send. */ + private val http = OkHttpClient.Builder() + .connectTimeout(4, TimeUnit.SECONDS) + .readTimeout(4, TimeUnit.SECONDS) + .callTimeout(6, TimeUnit.SECONDS) + .build() + + /** 64 KiB is plenty for a `
`, and a cap is what bounds a hostile page. */ + private const val MAX_BYTES = 64 * 1024 + + private val URL_RE = Regex("""https?://[^\s<>"']+""", RegexOption.IGNORE_CASE) + + /** The first http(s) URL in a message, or null. */ + fun firstUrl(text: String): String? = URL_RE.find(text)?.value?.trimEnd('.', ',', ')') + + /** + * Fetch and parse Open Graph tags. Null for anything unusual — no title, a + * refusal, a timeout — each of which leaves a message with a plain URL in + * it, which is a perfectly good thing to be. + */ + fun fetch(url: String): Content.LinkPreview? = runCatching { + val request = Request.Builder() + .url(url) + .header("Accept", "text/html") + // Identifies as a link preview rather than impersonating a browser: + // a site that wants to refuse these should be able to. + .header("User-Agent", "FlamenetLinkPreview/1.0") + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) return null + val head = response.body?.source()?.let { source -> + source.request(MAX_BYTES.toLong()) + source.buffer.snapshot(minOf(MAX_BYTES.toLong(), source.buffer.size).toInt()) + .utf8() + } ?: return null + + val title = meta(head, "og:title") ?: titleTag(head) ?: return null + val desc = meta(head, "og:description") ?: metaName(head, "description") ?: "" + Content.LinkPreview.of(url, title, desc) + } + }.getOrNull() + + // ⚠️ Regex over a bounded prefix, deliberately. A full HTML parser for two + // attributes is a dependency and an attack surface; this reads what it + // recognises and gives up otherwise, which is the correct failure here. + + private fun meta(html: String, property: String): String? = + Regex("""]+property=["']${Regex.escape(property)}["'][^>]+content=["']([^"']*)["']""", + RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean) + ?: Regex("""]+content=["']([^"']*)["'][^>]+property=["']${Regex.escape(property)}["']""", + RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean) + + private fun metaName(html: String, name: String): String? = + Regex("""]+name=["']${Regex.escape(name)}["'][^>]+content=["']([^"']*)["']""", + RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean) + + private fun titleTag(html: String): String? = + Regex("""