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 77da1d8..359f374 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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()) { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MediaScrub.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MediaScrub.kt new file mode 100644 index 0000000..ab37457 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaScrub.kt @@ -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() + 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 +} 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 a4e0fe5..e1f066f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt @@ -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" } } 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 7335dbe..43b5ca2 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 -> 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 9458092..4374c31 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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 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 a13b8aa..be3fb99 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt @@ -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. */ diff --git a/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt b/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt index 0ed399c..0d3b5bd 100644 --- a/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt @@ -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)