Android was sending the original photo file

The picker read the file and handed the ORIGINAL bytes to `sendImage` — no
downscale, no re-encode, nothing. A modern phone camera produces 12-50 MP and
often 5-15 MB of JPEG, so a photo either exceeded the relay's blob cap and
simply failed to send, or fit and made the recipient download tens of megabytes
nobody asked for. iOS has downscaled since it shipped, so the two platforms
disagreed about what sending a photo even means.

Adds `ImagePrep`, matching the iOS one: 2048px at quality 80 by default, 4096 at
95 with the new "Send full quality" switch, stepping down through size and
quality until the bytes are under budget. The budget sits under the server's cap
because the blob is encrypted before upload and the AEAD tag rides on top —
sizing to the exact limit produces a file that passes locally and is refused
remotely.

⚠️ Bounds are read with `inJustDecodeBounds` and the decode uses `inSampleSize`,
so the full-size bitmap never exists. Decoding a 50MP JPEG outright is ~200 MB
of bitmap, which is how a photo picker gets OOM-killed.

⚠️ "Full quality" is not "uncompressed", and the copy says so. A setting that
silently failed on a large photo would be a worse answer than the compression it
replaced.

engine 155, client 96, app 35.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 12:10:59 -04:00
co-authored by Claude Opus 4.8
parent 2481af6cc8
commit 020eba2228
3 changed files with 187 additions and 8 deletions
@@ -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,
)
}
@@ -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)
}
}
@@ -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"
}
}