Stories stop carrying where they were taken, and can outlast a day
**Location.** Two story paths handed the picked file over byte for byte:
- A story PHOTO was the one image path in the app that never went
through `ImagePrep`, so it was uploaded with its EXIF block intact —
including, for anything shot on the phone, the GPS coordinates. Chat
photos have always been re-encoded and were safe by consequence
(`Bitmap.compress` writes no EXIF); a story, which goes to every
contact at once, was not.
- A story VIDEO carried the capture location in the MP4 container.
This file's own doc said "a clip that fits goes out exactly as
picked", and that included where it was shot.
`MediaScrub` rebuilds the container with `MediaExtractor` +
`MediaMuxer`. A REMUX, not a transcode: the samples are copied across
untouched, so there is no quality loss and no codec work, and it is
cheap enough to run on every post — a scrub people wait for is a scrub
somebody eventually makes optional. Audio and video tracks only;
anything else a camera app decided to add is the kind of passenger this
exists to leave behind.
⚠️ Rotation IS carried over, deliberately. It says which way up the
phone was, not where it was, and dropping it plays every portrait story
on its side.
🔴 A scrub that fails REFUSES the post. "We could not remove the
location, so we sent it anyway" is the one outcome this must never
produce.
**Stories.** The lifetime was a constant. 1 hour / 6 hours / 24 hours /
3 days / 7 days, same set as iOS, defaulting to what it has always done.
`Conversations.postStory` already took `lifetimeSeconds` with a default,
so only the setting and the plumbing were missing — and
`Session.postStory` now REQUIRES it rather than defaulting, so a future
call site cannot silently ignore what the person chose.
⚠️ Clamped to the offered set on read: a value from a future build, or a
corrupted preference, must not post a story that expires before anybody
sees it.
Also fixes `RelayClientTest`, which had not compiled since `fetchKeys`
started returning the fnid alongside the bundles. Every other call site
in the tests was updated at the time; this one was missed, and I had not
run this suite since.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ccdf82c00a
commit
fcc6692284
@@ -1103,6 +1103,13 @@ private fun ColumnScope.AppLockSection() {
|
||||
* ⚠️ There is no "public" option and cannot be one. A story only ever goes to
|
||||
* contacts, because it has to be encrypted to somebody in particular.
|
||||
*/
|
||||
/** ⚠️ Plain words: "168 hours" would make the reader do the division. */
|
||||
private fun storyLifetimeLabel(hours: Int): String = when {
|
||||
hours < 24 -> if (hours == 1) "1 hour" else "$hours hours"
|
||||
hours == 24 -> "24 hours"
|
||||
else -> (hours / 24).let { if (it == 1) "1 day" else "$it days" }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.StoryAudienceSection(
|
||||
model: MessengerViewModel,
|
||||
@@ -1159,6 +1166,42 @@ private fun ColumnScope.StoryAudienceSection(
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(Space.lg))
|
||||
Text("How long your stories last", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
// 🔴 A day used to be the only answer. The expiry rides inside the sealed
|
||||
// story, so this is the poster's choice travelling with every copy — not a
|
||||
// rule the relay is being asked to keep.
|
||||
val storyContext = LocalContext.current
|
||||
val mediaSettings = remember { MediaSettings(storyContext) }
|
||||
var storyHours by remember { mutableStateOf(mediaSettings.storyLifetimeHours) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
MediaSettings.STORY_LIFETIME_CHOICES.forEach { hours ->
|
||||
KitChip(
|
||||
text = storyLifetimeLabel(hours),
|
||||
selected = storyHours == hours,
|
||||
onClick = {
|
||||
storyHours = hours
|
||||
mediaSettings.storyLifetimeHours = hours
|
||||
},
|
||||
tag = "story-lifetime-$hours",
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
// ⚠️ Says what it does NOT promise. "Disappears after" alone reads as a
|
||||
// guarantee about other people's phones, and a viewer who screenshots a
|
||||
// story has it for good.
|
||||
"Stories you post from now on disappear after this long, on your device "
|
||||
+ "and your contacts'. Ones already posted keep the time they were "
|
||||
+ "sent with.",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
|
||||
// The list only earns its place when the mode uses one.
|
||||
if (audience.mode != Session.AudienceMode.EVERYONE) {
|
||||
if (phase.buddies.isEmpty()) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Strip everything from a video except the pictures and the sound.
|
||||
*
|
||||
* 🔴 **A clip from the camera roll says where it was shot.** Android writes the
|
||||
* capture location into the MP4 container — the `©xyz` / `location` atom — and
|
||||
* the story path used to hand the picked file over byte for byte, so posting a
|
||||
* video told every viewer the coordinates of wherever the person happened to
|
||||
* be. Nothing on screen suggested it and no setting controlled it. Photos were
|
||||
* already safe by accident, because [ImagePrep] re-encodes them through a
|
||||
* bitmap and `Bitmap.compress` writes no EXIF; video had no equivalent step at
|
||||
* all.
|
||||
*
|
||||
* ⚠️ **A remux, not a transcode.** The samples are copied across untouched, so
|
||||
* there is no quality loss and no codec work — the container is rebuilt and the
|
||||
* metadata simply is not carried over. That is cheap enough to do on every
|
||||
* post, which matters: a scrub people wait for is a scrub somebody eventually
|
||||
* makes optional.
|
||||
*
|
||||
* ⚠️ Rotation IS carried over, deliberately. It says which way up the phone
|
||||
* was, not where it was, and dropping it plays every portrait clip on its side.
|
||||
*/
|
||||
object MediaScrub {
|
||||
|
||||
/**
|
||||
* @param source the picked video's bytes.
|
||||
* @param rotationDegrees from `METADATA_KEY_VIDEO_ROTATION`, re-applied to
|
||||
* the rebuilt file.
|
||||
* @return scrubbed bytes, or null if the file could not be rebuilt — the
|
||||
* caller must then refuse the post rather than fall back to the original.
|
||||
*/
|
||||
fun videoWithoutMetadata(
|
||||
source: ByteArray,
|
||||
rotationDegrees: Int,
|
||||
cacheDir: File,
|
||||
): ByteArray? {
|
||||
val input = File.createTempFile("scrub-in", ".mp4", cacheDir)
|
||||
val output = File.createTempFile("scrub-out", ".mp4", cacheDir)
|
||||
var extractor: MediaExtractor? = null
|
||||
var muxer: MediaMuxer? = null
|
||||
var started = false
|
||||
return try {
|
||||
input.writeBytes(source)
|
||||
|
||||
extractor = MediaExtractor()
|
||||
extractor.setDataSource(input.absolutePath)
|
||||
muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
|
||||
// Audio and video only. Anything else in the file — timed text, a
|
||||
// sensor track, whatever a particular camera app decided to add —
|
||||
// is exactly the kind of passenger this exists to leave behind.
|
||||
val trackMap = HashMap<Int, Int>()
|
||||
var bufferSize = DEFAULT_BUFFER
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
||||
if (!mime.startsWith("video/") && !mime.startsWith("audio/")) continue
|
||||
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||
bufferSize = maxOf(bufferSize, format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
|
||||
}
|
||||
extractor.selectTrack(i)
|
||||
trackMap[i] = muxer.addTrack(format)
|
||||
}
|
||||
if (trackMap.isEmpty()) return null
|
||||
|
||||
muxer.setOrientationHint(rotationDegrees)
|
||||
muxer.start()
|
||||
started = true
|
||||
|
||||
val buffer = ByteBuffer.allocate(bufferSize)
|
||||
val info = MediaCodec.BufferInfo()
|
||||
while (true) {
|
||||
val track = extractor.sampleTrackIndex
|
||||
if (track < 0) break
|
||||
val size = extractor.readSampleData(buffer, 0)
|
||||
if (size < 0) break
|
||||
val target = trackMap[track]
|
||||
if (target != null) {
|
||||
info.offset = 0
|
||||
info.size = size
|
||||
info.presentationTimeUs = extractor.sampleTime
|
||||
// ⚠️ Mapped rather than assigned across. The extractor's
|
||||
// flags and the codec's are different vocabularies that
|
||||
// happen to agree on the value of "sync sample" today.
|
||||
info.flags =
|
||||
if (extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0) {
|
||||
MediaCodec.BUFFER_FLAG_KEY_FRAME
|
||||
} else {
|
||||
0
|
||||
}
|
||||
muxer.writeSampleData(target, buffer, info)
|
||||
}
|
||||
extractor.advance()
|
||||
}
|
||||
output.readBytes()
|
||||
} catch (failure: Exception) {
|
||||
null
|
||||
} finally {
|
||||
if (started) runCatching { muxer?.stop() }
|
||||
runCatching { muxer?.release() }
|
||||
runCatching { extractor?.release() }
|
||||
runCatching { input.delete() }
|
||||
runCatching { output.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_BUFFER = 1 shl 20
|
||||
}
|
||||
@@ -24,6 +24,31 @@ class MediaSettings(context: Context) {
|
||||
get() = prefs.getBoolean(FULL_QUALITY, false)
|
||||
set(value) = prefs.edit().putBoolean(FULL_QUALITY, value).apply()
|
||||
|
||||
/**
|
||||
* How long a story posted from this phone stays visible, in hours.
|
||||
*
|
||||
* 🔴 **A day was the only answer the app had.** The lifetime was a
|
||||
* constant, so the one thing people actually want to change about a story
|
||||
* — how long it lasts — was the one thing they could not.
|
||||
*
|
||||
* ⚠️ The choice is the SENDER's and rides inside the sealed envelope
|
||||
* (`exp`, §6.6), so it does not ask the relay to keep a promise: every copy
|
||||
* carries the expiry its poster chose.
|
||||
*
|
||||
* ⚠️ Clamped to the offered set on read. A value from a future build, or a
|
||||
* corrupted preference, must not post a story that expires before anybody
|
||||
* sees it — which is what a raw 0 would do.
|
||||
*/
|
||||
var storyLifetimeHours: Int
|
||||
get() {
|
||||
val stored = prefs.getInt(STORY_LIFETIME_HOURS, STORY_LIFETIME_DEFAULT)
|
||||
return if (stored in STORY_LIFETIME_CHOICES) stored else STORY_LIFETIME_DEFAULT
|
||||
}
|
||||
set(value) = prefs.edit().putInt(STORY_LIFETIME_HOURS, value).apply()
|
||||
|
||||
/** Seconds, as [io.flamenet.client.Conversations.postStory] wants it. */
|
||||
val storyLifetimeSeconds: Double get() = storyLifetimeHours * 60.0 * 60.0
|
||||
|
||||
/**
|
||||
* Refuse to send unless the relay can prove the recipient's keys appear in
|
||||
* the log it publishes.
|
||||
@@ -61,6 +86,10 @@ class MediaSettings(context: Context) {
|
||||
private const val PREFS = "media-settings"
|
||||
private const val REQUIRE_KEY_LOG = "requireKeyLog"
|
||||
private const val FULL_QUALITY = "full_quality"
|
||||
private const val STORY_LIFETIME_HOURS = "stories.lifetimeHours"
|
||||
const val STORY_LIFETIME_DEFAULT = 24
|
||||
/** ⚠️ Same set as iOS, so the two apps offer the same thing. */
|
||||
val STORY_LIFETIME_CHOICES = listOf(1, 6, 24, 72, 168)
|
||||
private const val LINK_PREVIEWS = "link_previews"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1963,7 +1963,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.postStory(bytes, mime, width, height, caption, recipients,
|
||||
duration, streamUrl)
|
||||
duration, streamUrl,
|
||||
lifetimeSeconds = MediaSettings(getApplication())
|
||||
.storyLifetimeSeconds)
|
||||
}
|
||||
}
|
||||
done.onSuccess { posted ->
|
||||
|
||||
@@ -906,8 +906,16 @@ class Session(
|
||||
duration: Double = 0.0,
|
||||
/** A live stream this story embeds (§6.13); [bytes] is then the poster. */
|
||||
streamUrl: String? = null,
|
||||
/**
|
||||
* ⚠️ Passed explicitly rather than left to the client's default. The
|
||||
* default is 24 hours and the setting exists precisely so that is not
|
||||
* the only answer; a call site that omits it silently ignores what the
|
||||
* person chose.
|
||||
*/
|
||||
lifetimeSeconds: Double,
|
||||
): io.flamenet.client.Conversations.StoryPosted {
|
||||
val posted = conversations.postStory(bytes, mime, width, height, caption, toUserIds,
|
||||
lifetimeSeconds = lifetimeSeconds,
|
||||
duration = duration, streamUrl = streamUrl)
|
||||
if (posted.reached > 0) {
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
|
||||
@@ -28,14 +28,23 @@ import java.io.File
|
||||
/**
|
||||
* Staging a picked story, image or video, from either entry point.
|
||||
*
|
||||
* Videos are validated, not transcoded: this platform has no built-in
|
||||
* exporter the way AVFoundation is one, and pulling in a transcoding stack
|
||||
* is a decision for later. So the caps are enforced honestly — a clip too
|
||||
* long or too heavy is refused with the reason, and a clip that fits goes
|
||||
* out exactly as picked.
|
||||
* Videos are validated and scrubbed, not transcoded: this platform has no
|
||||
* built-in exporter the way AVFoundation is one, and pulling in a transcoding
|
||||
* stack is a decision for later. So the caps are enforced honestly — a clip too
|
||||
* long or too heavy is refused with the reason — and a clip that fits is
|
||||
* remuxed to drop the container metadata before it goes out. See [MediaScrub];
|
||||
* "exactly as picked" used to include the capture location.
|
||||
*/
|
||||
object StoryMedia {
|
||||
|
||||
/** Duration in seconds, display dimensions, and the container rotation. */
|
||||
private data class Meta(
|
||||
val seconds: Double,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val rotation: Int,
|
||||
)
|
||||
|
||||
/** A story is a moment, not a film — same cap as iOS. */
|
||||
const val MAX_DURATION_S = 30
|
||||
/** Under the relay's 10 MB attachment cap, with room for the seal. */
|
||||
@@ -66,27 +75,40 @@ object StoryMedia {
|
||||
?.toIntOrNull() ?: 0
|
||||
// A portrait phone clip is stored landscape plus a rotation.
|
||||
if (rotation == 90 || rotation == 270) { val t = w; w = h; h = t }
|
||||
Triple(durationMs / 1000.0, w, h)
|
||||
// ⚠️ The rotation is carried out of here now, not just used to
|
||||
// swap the dimensions: rebuilding the container to strip its
|
||||
// metadata drops the orientation with everything else, and a
|
||||
// portrait story would play on its side.
|
||||
Meta(durationMs / 1000.0, w, h, rotation)
|
||||
}.getOrNull().also { runCatching { retriever.release() } }
|
||||
|
||||
if (meta == null) {
|
||||
model.reportStoryProblem("That video couldn't be read.")
|
||||
return
|
||||
}
|
||||
val (seconds, w, h) = meta
|
||||
val (seconds, w, h, rotation) = meta
|
||||
if (seconds > MAX_DURATION_S + 0.5) {
|
||||
model.reportStoryProblem(
|
||||
"Stories are capped at $MAX_DURATION_S seconds — this one is "
|
||||
+ "${seconds.toInt()}. Trim it and try again.")
|
||||
return
|
||||
}
|
||||
val bytes = runCatching {
|
||||
val picked = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (bytes == null) {
|
||||
if (picked == null) {
|
||||
model.reportStoryProblem("That video couldn't be read.")
|
||||
return
|
||||
}
|
||||
// 🔴 **Where it was shot does not go with it.** See [MediaScrub].
|
||||
// A failure here refuses the post rather than falling back to the
|
||||
// original: "we could not remove the location, so we sent it
|
||||
// anyway" is the one outcome this must never produce.
|
||||
val bytes = MediaScrub.videoWithoutMetadata(picked, rotation, context.cacheDir)
|
||||
if (bytes == null) {
|
||||
model.reportStoryProblem("That video couldn't be prepared for sending.")
|
||||
return
|
||||
}
|
||||
if (bytes.size > MAX_BYTES) {
|
||||
model.reportStoryProblem(
|
||||
"That video is too large to send — the cap is about 9 MB. "
|
||||
@@ -97,16 +119,25 @@ object StoryMedia {
|
||||
return
|
||||
}
|
||||
|
||||
val bytes = runCatching {
|
||||
val picked = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (bytes == null) {
|
||||
if (picked == null) {
|
||||
model.reportStoryProblem("That file couldn't be read.")
|
||||
return
|
||||
}
|
||||
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
model.stageStory(bytes, mime, bounds.outWidth, bounds.outHeight)
|
||||
// 🔴 **Prepared, not posted as picked.** A story photo was the one
|
||||
// image path in the app that never went through [ImagePrep], so it was
|
||||
// uploaded with its EXIF block intact — including, for anything shot on
|
||||
// the phone, the GPS coordinates. Chat photos have always been
|
||||
// re-encoded and were safe by consequence; a story, which is shown to
|
||||
// every contact at once, was not.
|
||||
val prepared = ImagePrep.prepare(picked, MediaSettings(context).fullQuality)
|
||||
if (prepared == null) {
|
||||
model.reportStoryProblem("That picture couldn't be read.")
|
||||
return
|
||||
}
|
||||
model.stageStory(prepared.bytes, prepared.mime, prepared.width, prepared.height)
|
||||
}
|
||||
|
||||
/** The first frame, for the composer's preview of a staged video. */
|
||||
|
||||
@@ -293,7 +293,11 @@ class RelayClientTest {
|
||||
.put("opk", JSONObject().put("id", oneTime.id).put("pub", Wire.b64(oneTime.publicKey)))
|
||||
server.enqueue(json("""{"devices":[$wire]}"""))
|
||||
|
||||
val bundles = client().fetchKeys(42)
|
||||
// ⚠️ `fetchKeys` answers with the fnid alongside the bundles now — the
|
||||
// address is what the key log is checked against, and returning only
|
||||
// the bundles threw it away at the one point it is available.
|
||||
val fetched = client().fetchKeys(42)
|
||||
val bundles = fetched.bundles
|
||||
assertEquals(1, bundles.size)
|
||||
assertEquals(peer.deviceId, bundles[0].deviceId)
|
||||
assertEquals(oneTime.id, bundles[0].oneTimePreKeyId)
|
||||
|
||||
Reference in New Issue
Block a user