Embedded live streams in stories, behind a disclosure (§6.13)
Every other media path here is a sealed blob — the relay stores ciphertext and nobody else is involved. A live stream is not that: it is continuous and hosted elsewhere, so there is nothing to seal, and watching one means this device connects to that host, which learns the viewer's IP address and that they watched. 🔴 **§6.12's answer does not transfer.** A link preview works because the sender fetches it once and puts the result inside the ciphertext. A stream cannot be fetched once. So this is disclosure rather than prevention. The URL rides inside the ciphertext, so the relay learns nothing about what is watched or by whom. Playback is an explicit tap with the host named and what it learns spelled out — a card that started playing on its own would make that disclosure after the fact, which is not a disclosure. ⚠️ The attachment is the POSTER, not the stream. Keeping a real blob means the strip, the unseen rings and the expiry sweep all work unchanged, and the viewer sees a still before deciding whether to connect. ⚠️ The host is derived from the URL, never the caption — a story captioned "youtube.com" pointing elsewhere is the phishing shape link previews already guard against. Non-http(s) URLs and ones with no host are refused: a `file:` URL arriving from another person is not something to hand a media player, and a destination nobody can name is one nobody can consent to. The wording lives in the engine rather than the UI so it can be asserted. engine 177, client 96, app 46. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b8ffe2ea71
commit
1bbf8b460b
@@ -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)
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user