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:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user