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:
Trey t
2026-05-01 14:39:26 -07:00
parent 418ffc7772
commit 3cd115a436
9 changed files with 802 additions and 37 deletions
@@ -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)
}
}
@@ -13,6 +13,38 @@ data class TaskCompletionCreateRequest(
val notes: String? = null,
@SerialName("actual_cost") val actualCost: Double? = null,
val rating: Int? = null, // 1-5 star rating
@SerialName("image_urls") val imageUrls: List<String>? = null // Multiple image URLs
@SerialName("image_urls") val imageUrls: List<String>? = null, // Legacy: URLs returned by /api/uploads/* multipart endpoints
@SerialName("upload_ids") val uploadIds: List<Int>? = null // New: pending_uploads.id values from /api/uploads/presign + direct B2 POST
)
/**
* Presigned upload session — request body for POST /api/uploads/presign.
*
* Category: "completion" | "document_image" | "document_file"
* ContentType: the MIME type the client will upload (must match the policy
* exactly when POSTing to B2).
* ContentLength: byte count of the upload (server permits ±256 bytes slack).
*/
@Serializable
data class PresignUploadRequest(
val category: String,
@SerialName("content_type") val contentType: String,
@SerialName("content_length") val contentLength: Long
)
/**
* Presigned upload session — response from POST /api/uploads/presign.
*
* The client uses [uploadUrl] + [fields] to perform a multipart/form-data
* POST directly to B2, then passes [id] back in the upload_ids[] field of
* the next /api/task-completions/ or /api/documents/ create call.
*/
@Serializable
data class PresignUploadResponse(
val id: Int,
@SerialName("upload_url") val uploadUrl: String,
val fields: Map<String, String>,
val key: String,
@SerialName("expires_at") val expiresAt: String
)
@@ -27,6 +27,7 @@ object APILayer {
private val notificationApi = NotificationApi()
private val subscriptionApi = SubscriptionApi()
private val taskTemplateApi = TaskTemplateApi()
private val uploadApi = UploadApi()
// ==================== Initialization Guards ====================
@@ -1375,6 +1376,42 @@ object APILayer {
return result
}
// ==================== Upload Operations ====================
/**
* Direct-to-B2 image upload. The bytes are POSTed straight to Backblaze
* — they never touch our API server. Returns the pending_uploads.id
* which the caller passes back via `upload_ids[]` on the next entity-
* creation call (task completion, document, etc.).
*
* Caller responsibilities:
* - Pre-downsample to a sensible size before calling. Use the
* platform-specific ImageDownsampler (Android) or
* ImageDownsampler.swift (iOS).
* - Pass [contentType] matching the bytes (typically "image/jpeg").
* - Pass a [fileName] for B2's metadata. Need not be unique — the
* server picks the actual storage key.
*
* Errors at either step (presign or B2 POST) surface as ApiResult.Error.
* Partial state (presign succeeded but B2 POST failed) is reaped by
* the server-side cleanup cron within an hour.
*/
suspend fun uploadImage(
category: String,
contentType: String,
bytes: ByteArray,
fileName: String,
): ApiResult<Int> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return uploadApi.uploadOne(
token = token,
category = category,
contentType = contentType,
data = bytes,
fileName = fileName,
)
}
// ==================== Notification Operations ====================
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {
@@ -0,0 +1,149 @@
package com.tt.honeyDue.network
import com.tt.honeyDue.models.PresignUploadRequest
import com.tt.honeyDue.models.PresignUploadResponse
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.utils.io.core.*
/**
* Three-step direct-to-B2 upload helper.
*
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a
* B2 POST policy plus form fields the client needs to perform the
* direct upload.
* Step 2: [postToStorage] — multipart/form-data POST straight to B2.
* Bytes never traverse our API server.
* Step 3: caller invokes the relevant entity-creation endpoint
* (POST /api/task-completions/, POST /api/documents/) with the
* returned upload_id in the `upload_ids` field.
*
* iOS uses its own native equivalent (PresignedUploader.swift) for memory
* reasons — Swift can stream a multipart body without buffering. Android
* uses this Kotlin path which works fine for ≤10 MB images.
*/
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
/** Step 1 — request a signed POST policy. */
suspend fun presign(
token: String,
category: String,
contentType: String,
contentLength: Long,
): ApiResult<PresignUploadResponse> {
return try {
val response = client.post("$baseUrl/uploads/presign/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(PresignUploadRequest(category, contentType, contentLength))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error(
when (response.status.value) {
413 -> "That photo is too large after resizing."
422 -> "That image format isn't supported."
429 -> "Too many uploads in flight; try again shortly."
else -> "Couldn't start upload (HTTP ${response.status.value})."
},
response.status.value,
)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Network error during presign")
}
}
/**
* Step 2 — POST `data` directly to B2 using the signed policy fields.
*
* The S3 POST policy spec requires every signed field to appear before
* the file part, and `key` + `Content-Type` must match the policy
* exactly. Ktor's MultiPartFormDataContent preserves insertion order
* for the appended parts.
*/
suspend fun postToStorage(
uploadUrl: String,
fields: Map<String, String>,
data: ByteArray,
contentType: String,
fileName: String,
): ApiResult<Unit> {
return try {
val parts = formData {
// Stable order: signed fields first, then file. We rely on
// Ktor preserving the order in which append() is called.
fields.forEach { (k, v) -> append(k, v) }
append(
key = "file",
value = data,
headers = Headers.build {
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
append(HttpHeaders.ContentType, contentType)
},
)
}
val response = client.submitFormWithBinaryData(
url = uploadUrl,
formData = parts,
)
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
val body = try {
response.bodyAsText()
} catch (_: Throwable) {
""
}
ApiResult.Error(
"Upload to storage failed (HTTP ${response.status.value}): $body",
response.status.value,
)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Network error during upload")
}
}
/**
* Step 1 + Step 2 in one call. Returns the upload_id the caller passes
* back via upload_ids[] on the entity-creation endpoint.
*
* Errors at either step short-circuit and surface up — the partial
* pending_uploads row created at presign time will be reaped by the
* server-side hourly cleanup cron.
*/
suspend fun uploadOne(
token: String,
category: String,
contentType: String,
data: ByteArray,
fileName: String,
): ApiResult<Int> {
val presignResult = presign(token, category, contentType, data.size.toLong())
val presigned = (presignResult as? ApiResult.Success)?.data
?: return ApiResult.Error(
(presignResult as? ApiResult.Error)?.message ?: "Presign failed",
(presignResult as? ApiResult.Error)?.code,
)
val postResult = postToStorage(
uploadUrl = presigned.uploadUrl,
fields = presigned.fields,
data = data,
contentType = contentType,
fileName = fileName,
)
return when (postResult) {
is ApiResult.Success -> ApiResult.Success(presigned.id)
is ApiResult.Error -> postResult
else -> ApiResult.Error("Upload failed in unknown state")
}
}
}