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:
co-authored by
Claude Opus 4.8
parent
2481af6cc8
commit
020eba2228
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user