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 27594e3..fe017fd 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -664,6 +664,9 @@ fun ColumnScope.AccountScreen( SectionBand() SettingsSection { AppLockSection() } + SectionBand() + SettingsSection { MediaQualitySection() } + SectionBand() SettingsSection { RegisteredDevicesSection(model, phase) } @@ -1462,14 +1465,13 @@ fun ColumnScope.ChatScreen( val bytes = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull() ?: return@rememberLauncherForActivityResult - val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } - android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) - model.sendImage( - bytes, - context.contentResolver.getType(uri) ?: "image/jpeg", - bounds.outWidth, - bounds.outHeight, - ) + // 🔴 The ORIGINAL bytes used to go straight out. A modern camera photo + // is 5-15 MB and often over the relay's blob cap, so large pictures + // simply failed to send — and the ones that fit made the recipient + // download tens of megabytes nobody asked for. + val prepared = ImagePrep.prepare(bytes, MediaSettings(context).fullQuality) + ?: return@rememberLauncherForActivityResult + model.sendImage(prepared.bytes, prepared.mime, prepared.width, prepared.height) } // The kit's composer: a 50-tall pill with radius 25, a leading attach @@ -2686,3 +2688,46 @@ internal fun registeredLine(createdAt: String?): String { "Registered ${fmt.format(instant)}" }.getOrDefault("Registered $raw") } + +/** + * How photos are sent. + * + * 🔴 **This app sent the ORIGINAL file.** The picker read the bytes and handed + * them straight on, so a modern camera photo — often 5-15 MB — went at the + * relay's blob cap directly: over it the send simply failed, under it the + * recipient downloaded tens of megabytes nobody asked for. iOS has downscaled + * since it shipped, so the two platforms disagreed about what sending a photo + * even means. + */ +@Composable +private fun ColumnScope.MediaQualitySection() { + val context = LocalContext.current + val settings = remember { MediaSettings(context) } + var full by remember { mutableStateOf(settings.fullQuality) } + + Text("Photos", style = MaterialTheme.typography.titleMedium) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Send full quality", fontSize = 16.sp, modifier = Modifier.weight(1f)) + Switch( + checked = full, + onCheckedChange = { on -> settings.fullQuality = on; full = on }, + modifier = Modifier.testTag("full-quality-media"), + ) + } + + // ⚠️ 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. + Text( + "Photos are sent at up to " + (if (full) "4096" else "2048") + " pixels on the " + + "longest side. Very large photos are reduced further until they fit what " + + "the server accepts — a photo that failed to send would be worse than a " + + "slightly softer one.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt new file mode 100644 index 0000000..e8327e8 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt @@ -0,0 +1,103 @@ +package io.flamenet.messenger.app + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.io.ByteArrayOutputStream + +/** + * Downscale and re-encode a picked photo before it is encrypted and uploaded. + * + * 🔴 **This app did none of this.** The picker read the file and handed the + * ORIGINAL bytes straight to `sendImage`, so a modern phone camera — 12 to 50 + * megapixels, often 5-15 MB of JPEG — went at the relay's blob cap directly. + * Over the cap the upload is refused and the photo simply fails to send; under + * it, the recipient downloads tens of megabytes nobody asked for. iOS has + * downscaled since it shipped, so the two platforms disagreed about what + * sending a photo even means. + * + * ⚠️ There is a hard ceiling and it is the server's, so "full quality" cannot + * mean "whatever the camera produced". It means *as good as will fit*: start at + * the requested quality and step down until the bytes are under budget. A photo + * that fails to send is worse than a photo that is slightly softer. + */ +object ImagePrep { + + /** + * How much of the server's cap a prepared image may use. + * + * Deliberately under the relay's 10 MiB: the blob is encrypted before + * upload and the AEAD tag and framing ride on top of these bytes. Sizing to + * the exact cap produces a file that passes here and is refused there. + */ + const val BYTE_BUDGET = 9 * 1024 * 1024 + + private const val STANDARD_MAX_SIDE = 2048 + private const val FULL_MAX_SIDE = 4096 + + data class Prepared(val bytes: ByteArray, val mime: String, val width: Int, val height: Int) + + /** + * @param fullQuality the user's "send full quality photos" setting. + * @return the prepared image, or null if the bytes are not a decodable image. + */ + fun prepare(source: ByteArray, fullQuality: Boolean): Prepared? { + val maxSide = if (fullQuality) FULL_MAX_SIDE else STANDARD_MAX_SIDE + + // Bounds first, so an enormous file is never fully decoded into memory + // just to find out how big it is. Decoding a 50MP JPEG at full size is + // ~200 MB of bitmap and is how a photo picker gets OOM-killed. + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(source, 0, source.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val decoded = decodeScaled(source, bounds, maxSide) ?: return null + + // Steps, best first. Each is a real fallback rather than a retry: for a + // very large picture quality alone will not reach the budget and the + // dimensions have to give as well. + val attempts = listOf( + maxSide to (if (fullQuality) 95 else 80), + maxSide to 70, + minOf(maxSide, 2048) to 70, + 1600 to 60, + 1024 to 50, + ) + + var last: Prepared? = null + for ((side, quality) in attempts) { + val scaled = scaleTo(decoded, side) + val out = ByteArrayOutputStream() + scaled.compress(Bitmap.CompressFormat.JPEG, quality, out) + if (scaled !== decoded) scaled.recycle() + val prepared = Prepared(out.toByteArray(), "image/jpeg", scaled.width, scaled.height) + last = prepared + if (prepared.bytes.size <= BYTE_BUDGET) { + decoded.recycle() + return prepared + } + } + decoded.recycle() + // Every step was still too large. Returning the smallest is better than + // null: the caller surfaces the server's refusal, where null here would + // look like an unreadable file instead. + return last + } + + /** Decode with `inSampleSize`, so the full-size bitmap never exists. */ + private fun decodeScaled(source: ByteArray, bounds: BitmapFactory.Options, maxSide: Int): Bitmap? { + var sample = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSide * 2) sample *= 2 + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + return BitmapFactory.decodeByteArray(source, 0, source.size, opts) + } + + /** Scale so the longest side is at most [maxSide]. Never enlarges. */ + private fun scaleTo(bitmap: Bitmap, maxSide: Int): Bitmap { + val longest = maxOf(bitmap.width, bitmap.height) + if (longest <= maxSide) return bitmap + val ratio = maxSide.toDouble() / longest + val w = maxOf(1, (bitmap.width * ratio).toInt()) + val h = maxOf(1, (bitmap.height * ratio).toInt()) + return Bitmap.createScaledBitmap(bitmap, w, h, true) + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt new file mode 100644 index 0000000..efaf515 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt @@ -0,0 +1,31 @@ +package io.flamenet.messenger.app + +import android.content.Context + +/** + * How photos are prepared before they are sent. + * + * ⚠️ Ordinary SharedPreferences, not the vault. This records a preference, not + * a secret — and the picker needs to read it on a path that must not depend on + * the encrypted store being open. + */ +class MediaSettings(context: Context) { + + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** + * Send photos at the highest quality that will still fit. + * + * ⚠️ Not "uncompressed". The relay refuses a blob over its cap, so this + * raises the ceiling and [ImagePrep] steps down until the bytes fit — a + * photo that failed to send would be worse than a slightly softer one. + */ + var fullQuality: Boolean + get() = prefs.getBoolean(FULL_QUALITY, false) + set(value) = prefs.edit().putBoolean(FULL_QUALITY, value).apply() + + companion object { + private const val PREFS = "media-settings" + private const val FULL_QUALITY = "full_quality" + } +}