feat(uploads): direct-to-B2 presigned image upload from iOS + Android
iOS (Swift) — primary path, since iOS is the live platform:
- ImageDownsampler.swift: ImageIO/CGImageSourceCreateThumbnailAtIndex
based resize. Pays only the cost of the resized bitmap rather than
decoding the full source — a 12 MP iPhone photo previously
materialized ~50 MB regardless of JPEG size. Profiles: completion
(2048 px / quality 0.85), document_image (2560 px / 0.90).
- PresignedUploader.swift: three-step orchestration (POST /uploads/presign
→ multipart POST direct to B2 with the signed policy fields → return
upload_id). Maps HTTP errors to user-facing copy. Concurrent uploads
via TaskGroup.
- CompleteTaskView.swift: replaces the multipart-with-images path with
downsample → upload-to-B2 → create-completion-with-upload_ids[]. The
no-image branch unchanged.
Android (Kotlin) — parity:
- composeApp/.../media/ImageDownsampler.kt: BitmapFactory inSampleSize
+ proportional scale + JPEG compress. Same profiles as iOS.
- composeApp/.../network/UploadApi.kt: Ktor-based presign + direct-to-B2
POST. Preserves form-field order so the S3 policy signature validates.
- APILayer.uploadImage(category, contentType, bytes, fileName) → upload_id.
UI integration to follow.
Shared (Kotlin):
- models/TaskCompletion.kt: added uploadIds: List<Int>? to
TaskCompletionCreateRequest and a new PresignUploadRequest /
PresignUploadResponse pair matching the Go API DTOs.
- Existing call sites (WidgetActionProcessor, PushNotificationManager)
explicitly pass uploadIds: nil for backwards compatibility — Swift's
bridge to Kotlin doesn't honor Kotlin defaults for required-positional
parameters.
The legacy multipart path remains functional alongside the new one for
soak-test purposes; per-platform feature flags can flip between them at
any time. After zero multipart traffic in production for 7 consecutive
days, the legacy paths can be dropped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package com.tt.honeyDue.media
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Memory-efficient image resizer for upload preprocessing on Android.
|
||||
*
|
||||
* Why not just decode + Bitmap.createScaledBitmap? createScaledBitmap
|
||||
* decodes the full source bitmap first — a 12 MP photo materializes ~50 MB
|
||||
* in RAM regardless of how big the JPEG is. That OOMs older devices.
|
||||
*
|
||||
* BitmapFactory.Options.inSampleSize, paired with inJustDecodeBounds=true
|
||||
* for a metadata-only first pass, lets us decode at a power-of-two
|
||||
* subsample. Combined with a final scaled-down draw, peak memory is
|
||||
* roughly proportional to the *output* bitmap's pixel count — not the
|
||||
* source's.
|
||||
*
|
||||
* Quality tuning matches WhatsApp-class apps: 2048 px max edge, JPEG 85.
|
||||
*/
|
||||
object ImageDownsampler {
|
||||
|
||||
data class Profile(
|
||||
val maxPixelEdge: Int,
|
||||
/** JPEG quality 0-100. */
|
||||
val jpegQuality: Int,
|
||||
) {
|
||||
companion object {
|
||||
val Completion = Profile(maxPixelEdge = 2048, jpegQuality = 85)
|
||||
val DocumentImage = Profile(maxPixelEdge = 2560, jpegQuality = 90)
|
||||
}
|
||||
}
|
||||
|
||||
/** Downsample raw image bytes into JPEG bytes ready for upload. */
|
||||
fun downsample(bytes: ByteArray, profile: Profile): ByteArray? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val sampleSize = computeSampleSize(bounds.outWidth, bounds.outHeight, profile.maxPixelEdge)
|
||||
val decodeOpts = BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize
|
||||
// ARGB_8888 keeps quality; on memory-constrained devices we
|
||||
// could drop to RGB_565 here, but for upload prep the extra
|
||||
// ~2x peak memory isn't worth the visible quality loss.
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
}
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
|
||||
?: return null
|
||||
|
||||
// Subsample is power-of-two only; the result may still be larger
|
||||
// than maxPixelEdge by up to 2x. One more proportional scale gets
|
||||
// us to the exact target.
|
||||
val scaled = scaleProportional(decoded, profile.maxPixelEdge)
|
||||
|
||||
val out = ByteArrayOutputStream(64 * 1024)
|
||||
val ok = scaled.compress(Bitmap.CompressFormat.JPEG, profile.jpegQuality, out)
|
||||
// Only recycle if scaled is a different bitmap; createScaledBitmap
|
||||
// sometimes returns the input unchanged, and recycling that would
|
||||
// double-recycle below.
|
||||
if (scaled !== decoded) decoded.recycle()
|
||||
scaled.recycle()
|
||||
return if (ok) out.toByteArray() else null
|
||||
}
|
||||
|
||||
/** Same, from a stream (for content:// URIs etc.). */
|
||||
fun downsample(input: InputStream, profile: Profile): ByteArray? {
|
||||
val bytes = input.use { it.readBytes() }
|
||||
return downsample(bytes, profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the largest power-of-two sub-sample factor that still yields
|
||||
* an image at least as large as maxPixelEdge on both axes. Mirrors
|
||||
* the canonical Android docs example.
|
||||
*/
|
||||
private fun computeSampleSize(srcW: Int, srcH: Int, maxEdge: Int): Int {
|
||||
var sample = 1
|
||||
var halfW = srcW / 2
|
||||
var halfH = srcH / 2
|
||||
while (halfW >= maxEdge && halfH >= maxEdge) {
|
||||
sample *= 2
|
||||
halfW /= 2
|
||||
halfH /= 2
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
private fun scaleProportional(src: Bitmap, maxEdge: Int): Bitmap {
|
||||
val w = src.width
|
||||
val h = src.height
|
||||
val longest = maxOf(w, h)
|
||||
if (longest <= maxEdge) return src
|
||||
val ratio = maxEdge.toFloat() / longest.toFloat()
|
||||
val newW = (w * ratio).toInt().coerceAtLeast(1)
|
||||
val newH = (h * ratio).toInt().coerceAtLeast(1)
|
||||
return Bitmap.createScaledBitmap(src, newW, newH, true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user