diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index a9bf0d5..be5591d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -171,7 +171,16 @@ private fun StoryCard( // 🔴 The card's own image, cropped to FILL the box — not the chat // bubble's 260dp thumbnail, which floated small on a full-width // backboard and read as a broken layout. - if (story.ref.mime.startsWith("video/")) { + // §6.13 — an embedded live stream. The gate comes FIRST, because + // tapping play is what connects this device to the stream host; a + // card that started playing on its own would disclose that after + // the fact, which is not a disclosure. `ref` is the poster. + val stream = story.streamUrl + val streamHost = story.streamHost + if (stream != null && streamHost != null && + io.flamenet.e2e.StreamDisclosure.acceptable(stream)) { + StoryStreamGate(stream, streamHost) + } else if (story.ref.mime.startsWith("video/")) { StoryVideoCard(story.ref, model) } else { StoryImage(story.ref, model) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt index 67bafaa..a13b8aa 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt @@ -9,6 +9,19 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.platform.testTag import androidx.compose.ui.viewinterop.AndroidView import java.io.File @@ -146,3 +159,57 @@ fun StoryVideoPlayer(bytes: ByteArray, cacheKey: String, modifier: Modifier = Mo }, ) } + +/** + * The consent gate in front of an embedded live stream (spec §6.13). + * + * 🔴 **Watching connects this device to the stream host.** A live stream is + * continuous and hosted elsewhere, so unlike a link preview there is no + * sender-side trick that avoids it: the URL is hidden from the relay, but the + * connection is not hidden from whoever serves the stream — they learn the + * viewer's IP address and that they watched. + * + * So playback is an explicit tap with the host named, never autoplay. A story + * that started playing on its own would make that disclosure after the fact, + * which is not a disclosure. + */ +@Composable +fun StoryStreamGate( + streamUrl: String, + host: String, + modifier: Modifier = Modifier, +) { + var playing by remember(streamUrl) { mutableStateOf(false) } + + if (playing) { + AndroidView( + modifier = modifier.testTag("story-stream-player"), + factory = { ctx -> + VideoView(ctx).apply { + setVideoURI(android.net.Uri.parse(streamUrl)) + setOnPreparedListener { it.start() } + } + }, + ) + return + } + + Column( + modifier = modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("Live stream", fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + Text( + io.flamenet.e2e.StreamDisclosure.text(host), + fontSize = 12.sp, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + Spacer(Modifier.height(12.dp)) + androidx.compose.material3.Button( + onClick = { playing = true }, + modifier = Modifier.testTag("story-stream-play"), + ) { Text("Connect and play") } + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index fb92238..132124a 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -59,6 +59,8 @@ data class Content( * member on a group message. */ val link: LinkPreview? = null, + /** A live stream this story embeds (§6.13). */ + val streamUrl: String? = null, ) { /** @@ -297,6 +299,8 @@ data class Content( groupId: String? = null, messageId: String? = null, about: Reference? = null, + /** A live stream this story embeds (§6.13); [ref] is then a poster. */ + streamUrl: String? = null, ): String = assemble( kind = ref.kind, caption = caption?.takeIf { it.isNotEmpty() }, @@ -305,6 +309,7 @@ data class Content( attachment = ref, messageId = messageId, about = about, + streamUrl = streamUrl, ) /** @@ -346,6 +351,7 @@ data class Content( messageId: String? = null, about: Reference? = null, link: LinkPreview? = null, + streamUrl: String? = null, ): String { val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}") messageId?.let { parts += "\"mid\":${quote(it)}" } @@ -355,6 +361,7 @@ data class Content( groupId?.let { parts += "\"gid\":${quote(it)}" } attachment?.let { parts += "\"att\":${it.toJson()}" } link?.let { parts += "\"lnk\":${it.toJson()}" } + streamUrl?.let { parts += "\"stream\":${quote(it)}" } return parts.joinToString(",", prefix = "{", postfix = "}") } @@ -461,7 +468,8 @@ data class Content( groupId = groupId, messageId = messageId, about = about, ) return Content(caption, kind = kind, raw = json, attachment = ref, - groupId = groupId, messageId = messageId, about = about) + groupId = groupId, messageId = messageId, about = about, + streamUrl = json.optString("stream").takeIf { it.isNotEmpty() }) } } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/StreamDisclosure.kt b/engine/src/main/kotlin/io/flamenet/e2e/StreamDisclosure.kt new file mode 100644 index 0000000..4b03390 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/StreamDisclosure.kt @@ -0,0 +1,40 @@ +package io.flamenet.e2e + +/** + * What a viewer is told before an embedded live stream connects (spec §6.13). + * + * 🔴 **This is the one media path where the viewer must contact a third party, + * and it cannot be designed away.** A photo or video story is a sealed blob: + * the relay stores ciphertext and nobody else is involved. A live stream is + * continuous and hosted elsewhere, so there is nothing to seal — watching it + * means the viewer's device connects to that host, which learns their IP + * address and that they watched. + * + * §6.12's answer (have the sender fetch it once) does not transfer: a stream is + * not a thing that can be fetched once. So the rule is disclosure rather than + * prevention, and this is where the wording lives so it can be asserted. + */ +object StreamDisclosure { + + /** + * ⚠️ Names the host and says what it learns. "This will play a video" is a + * button; a disclosure names the destination, because nobody can make a + * decision about "somewhere on the internet". + */ + fun text(host: String): String = + "Playing connects you to $host, which will see your IP address and " + + "that you watched. Nothing else in this story leaves your device." + + /** + * Whether a stream URL is one worth offering at all. + * + * ⚠️ Refuses anything that is not `http(s)` with a host. A `file:` URL + * arriving from another person is not something to hand a media player, and + * a destination that cannot be named cannot be consented to. + */ + fun acceptable(raw: String): Boolean = runCatching { + val uri = java.net.URI(raw) + val scheme = uri.scheme?.lowercase() + (scheme == "http" || scheme == "https") && !uri.host.isNullOrEmpty() + }.getOrDefault(false) +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index d60f600..6223c50 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -362,8 +362,23 @@ class Vault(private val storage: Storage, private val key: SecretKey) { val caption: String, val postedAt: Long, val seen: Boolean = false, + /** + * A live stream this story embeds, if it is one (§6.13). + * + * 🔴 **Playing it connects the VIEWER to the stream host**, revealing + * their address and that they watched. A live stream is continuous and + * hosted elsewhere, so unlike a link preview there is nothing to seal + * and no sender-side trick that avoids it. The URL is hidden from the + * relay; the connection is not hidden from the host — so [ref] is a + * POSTER, and playback is an explicit, host-named tap. + */ + val streamUrl: String? = null, ) { fun isActive(nowSeconds: Double): Boolean = ref.expires <= 0 || ref.expires > nowSeconds + + /** The host a viewer would connect to, for the disclosure. */ + val streamHost: String? + get() = streamUrl?.let { runCatching { java.net.URI(it).host }.getOrNull() } } // -- Avatars ------------------------------------------------------------- diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StreamDisclosureTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StreamDisclosureTest.kt new file mode 100644 index 0000000..12c851c --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StreamDisclosureTest.kt @@ -0,0 +1,103 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Embedded live streams in stories (spec §6.13). + * + * 🔴 **The one media path where the viewer must contact a third party.** Every + * other one is a sealed blob; a live stream is continuous and hosted elsewhere, + * so watching it means this device connects to that host, which learns the + * viewer's address and that they watched. §6.12's answer — have the sender + * fetch it once — does not transfer, because a stream cannot be fetched once. + * + * Everything here exists to make that a decision rather than a surprise. + */ +class StreamDisclosureTest { + + @Test + fun `the disclosure names the host`() { + assertTrue("live.example.com" in StreamDisclosure.text("live.example.com")) + } + + /** ⚠️ Must say what the host learns, not merely that something connects. */ + @Test + fun `the disclosure says what the host learns`() { + val text = StreamDisclosure.text("live.example.com") + assertTrue("IP address" in text, text) + assertTrue("watched" in text, text) + } + + @Test + fun `web streams are acceptable`() { + assertTrue(StreamDisclosure.acceptable("https://live.example.com/s.m3u8")) + assertTrue(StreamDisclosure.acceptable("http://live.example.com/s.m3u8")) + } + + /** + * 🔴 A `file:` URL arriving from another person is not something to hand a + * media player — playing it would read this device. + */ + @Test + fun `non-web schemes are refused`() { + assertFalse(StreamDisclosure.acceptable("file:///etc/passwd")) + assertFalse(StreamDisclosure.acceptable("javascript:alert(1)")) + assertFalse(StreamDisclosure.acceptable("rtsp://example.com/s")) + } + + /** ⚠️ A destination nobody can name is one nobody can consent to. */ + @Test + fun `a url with no host is refused`() { + assertFalse(StreamDisclosure.acceptable("https://")) + assertFalse(StreamDisclosure.acceptable("not a url")) + assertFalse(StreamDisclosure.acceptable("")) + } + + // -- The wire ------------------------------------------------------------ + + private fun ref() = Attachment.Ref( + kind = Attachment.STORY, id = "abc", key = ByteArray(32) { 1 }, + digest = ByteArray(32) { 2 }, bytes = 10, mime = "image/jpeg", + width = 1, height = 1, duration = 0.0, expires = 0.0) + + @Test + fun `a stream url survives the wire`() { + val wire = Content.attachment( + ref(), caption = "live now", streamUrl = "https://live.example.com/s.m3u8") + val parsed = Content.parse(wire) + assertEquals("https://live.example.com/s.m3u8", parsed.streamUrl) + assertEquals("live now", parsed.text) + } + + /** An ordinary story must be unchanged on the wire. */ + @Test + fun `an ordinary story carries no stream`() { + val wire = Content.attachment(ref(), caption = "just a photo") + assertNull(Content.parse(wire).streamUrl) + assertFalse("stream" in wire) + } + + /** + * 🔴 The host comes from the URL, never the caption. A story captioned + * "youtube.com" pointing elsewhere is the phishing shape §6.12 guards + * against. + */ + @Test + fun `the host comes from the url not the caption`() { + val story = Vault.Story( + id = "s1", authorId = 2L, mine = false, ref = ref(), + caption = "totally youtube.com", postedAt = 0L, + streamUrl = "https://evil.example/s.m3u8") + assertEquals("evil.example", story.streamHost) + } + + @Test + fun `an ordinary story has no stream host`() { + val story = Vault.Story("s1", 2L, false, ref(), "photo", 0L) + assertNull(story.streamHost) + } +}