From fa0ce30257d28c6456d65d6361e5c9d36098a57e Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 1 May 2026 14:39:26 -0700 Subject: [PATCH] feat(uploads): direct-to-B2 presigned image upload from iOS + Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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? 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) --- .../com/tt/honeyDue/media/ImageDownsampler.kt | 101 +++++++ .../com/tt/honeyDue/models/TaskCompletion.kt | 34 ++- .../com/tt/honeyDue/network/APILayer.kt | 37 +++ .../com/tt/honeyDue/network/UploadApi.kt | 149 ++++++++++ iosApp/iosApp/Helpers/ImageDownsampler.swift | 100 +++++++ iosApp/iosApp/Helpers/PresignedUploader.swift | 274 ++++++++++++++++++ .../Helpers/WidgetActionProcessor.swift | 3 +- .../PushNotificationManager.swift | 3 +- iosApp/iosApp/Task/CompleteTaskView.swift | 138 ++++++--- 9 files changed, 802 insertions(+), 37 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/media/ImageDownsampler.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt create mode 100644 iosApp/iosApp/Helpers/ImageDownsampler.swift create mode 100644 iosApp/iosApp/Helpers/PresignedUploader.swift diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/media/ImageDownsampler.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/media/ImageDownsampler.kt new file mode 100644 index 0000000..41f2c73 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/media/ImageDownsampler.kt @@ -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) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt index 11dc59a..76f7a82 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt @@ -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? = null // Multiple image URLs + @SerialName("image_urls") val imageUrls: List? = null, // Legacy: URLs returned by /api/uploads/* multipart endpoints + @SerialName("upload_ids") val uploadIds: List? = 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, + val key: String, + @SerialName("expires_at") val expiresAt: String ) diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt index 34afa2e..a4d5d45 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt @@ -27,6 +27,7 @@ object APILayer { private val notificationApi = NotificationApi() private val subscriptionApi = SubscriptionApi() private val taskTemplateApi = TaskTemplateApi() + private val uploadApi = UploadApi() // ==================== Initialization Guards ==================== @@ -1416,6 +1417,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 { + 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 { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt new file mode 100644 index 0000000..84ab6fe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt @@ -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 { + 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, + data: ByteArray, + contentType: String, + fileName: String, + ): ApiResult { + 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 { + 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") + } + } +} diff --git a/iosApp/iosApp/Helpers/ImageDownsampler.swift b/iosApp/iosApp/Helpers/ImageDownsampler.swift new file mode 100644 index 0000000..355cc0b --- /dev/null +++ b/iosApp/iosApp/Helpers/ImageDownsampler.swift @@ -0,0 +1,100 @@ +import Foundation +import ImageIO +import UIKit +import UniformTypeIdentifiers + +/// Memory-efficient image resizer for upload preprocessing. +/// +/// Why not `UIImage.jpegData(compressionQuality:)` directly? UIImage decodes +/// the entire source bitmap into RAM before re-encoding — a 12 MP iPhone +/// photo decompresses to ~50 MB regardless of how big the JPEG is. With +/// multiple selected images this can blow up memory on older devices. +/// +/// `CGImageSourceCreateThumbnailAtIndex` reads the source incrementally and +/// only allocates the *resized* bitmap, paying memory proportional to the +/// output size (a 2048×1536 thumbnail is ~12 MB, but the source is never +/// fully decoded). +/// +/// Reference: https://nshipster.com/image-resizing/ — section "Image I/O". +enum ImageDownsampler { + + /// Settings tuned per upload category. Edit here, not at call sites. + struct Profile { + /// Largest dimension (in points-after-scale, i.e. pixels) of the + /// downsampled image. The shorter edge is set proportionally. + let maxPixelEdge: CGFloat + + /// JPEG quality, 0...1. 0.85 is the WhatsApp / Slack default — + /// visually indistinguishable from quality 1.0 at typical viewing + /// sizes; cuts file size by ~3x. + let jpegQuality: CGFloat + + static let completion = Profile(maxPixelEdge: 2048, jpegQuality: 0.85) + static let documentImage = Profile(maxPixelEdge: 2560, jpegQuality: 0.90) + } + + /// Downsample raw image bytes (e.g. from a `PHPickerResult`'s + /// `loadDataRepresentation`) into a JPEG `Data` ready for upload. + /// + /// - Returns: encoded JPEG bytes, or nil if decoding failed. + static func downsample(data: Data, profile: Profile) -> Data? { + let options: [CFString: Any] = [ + kCGImageSourceShouldCache: false, // don't keep the full image around + kCGImageSourceTypeIdentifierHint: UTType.jpeg.identifier as CFString, // best-effort hint + ] + guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else { + return nil + } + return downsample(source: source, profile: profile) + } + + /// Downsample from a file URL (e.g. PhotosPicker's + /// `loadFileRepresentation`). Avoids materializing the full image in + /// memory before resize. + static func downsample(url: URL, profile: Profile) -> Data? { + let options: [CFString: Any] = [ + kCGImageSourceShouldCache: false, + ] + guard let source = CGImageSourceCreateWithURL(url as CFURL, options as CFDictionary) else { + return nil + } + return downsample(source: source, profile: profile) + } + + /// Convenience for callers that already have a `UIImage` (e.g. from + /// `UIImagePickerController`). We round-trip through PNG to get raw + /// data, then use the data path. Slightly less efficient than starting + /// from URL/Data, but still avoids the JPEG re-encode penalty for the + /// resize step itself. + static func downsample(uiImage: UIImage, profile: Profile) -> Data? { + // Use PNG for the intermediate to avoid double-JPEG quality loss. + // Even though PNG is larger, this stays in memory only briefly. + guard let intermediate = uiImage.pngData() else { return nil } + return downsample(data: intermediate, profile: profile) + } + + // MARK: - Internal + + private static func downsample(source: CGImageSource, profile: Profile) -> Data? { + // Compute the max pixel size in screen-resolution-aware units. We + // use a fixed pixel cap because uploads are about bytes, not display. + let scale: CGFloat = 1.0 + let maxDimensionInPixels = profile.maxPixelEdge * scale + + let downsampleOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceShouldCacheImmediately: true, // decode on the calling thread + kCGImageSourceCreateThumbnailWithTransform: true, // honor EXIF orientation + kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels, + ] + + guard let cgImage = CGImageSourceCreateThumbnailAtIndex( + source, 0, downsampleOptions as CFDictionary + ) else { + return nil + } + + let uiImage = UIImage(cgImage: cgImage) + return uiImage.jpegData(compressionQuality: profile.jpegQuality) + } +} diff --git a/iosApp/iosApp/Helpers/PresignedUploader.swift b/iosApp/iosApp/Helpers/PresignedUploader.swift new file mode 100644 index 0000000..2f673ac --- /dev/null +++ b/iosApp/iosApp/Helpers/PresignedUploader.swift @@ -0,0 +1,274 @@ +import Foundation +import ComposeApp + +/// Three-step direct-to-B2 image upload. +/// +/// Flow: +/// 1. POST /api/uploads/presign → server returns a B2 POST policy + form +/// fields scoped to a single object key with a content-length-range +/// condition that B2 enforces at the protocol level. +/// 2. Multipart POST the bytes directly to B2, no API server in the data +/// path. B2 rejects the upload if the bytes don't match the policy. +/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or +/// /api/documents/ via `upload_ids[]`. The server HEADs the object, +/// confirms the size, and creates the linked entity rows. +/// +/// All errors map to `PresignedUploaderError` — the Swift call site can +/// translate to user-facing copy without parsing nested HTTP details. +enum PresignedUploaderError: Error, LocalizedError { + case notAuthenticated + case presignFailed(status: Int, body: String) + case uploadFailed(status: Int, body: String) + case sessionError(Error) + + var errorDescription: String? { + switch self { + case .notAuthenticated: + return "You're not signed in." + case .presignFailed(let status, _): + switch status { + case 413: return "That photo is too large after resizing. Try a different one." + case 422: return "That image format isn't supported." + case 429: return "You're uploading too many photos. Try again in a few minutes." + default: return "Couldn't start upload (server returned \(status))." + } + case .uploadFailed(let status, _): + return "Upload failed (B2 returned \(status))." + case .sessionError(let err): + return err.localizedDescription + } + } +} + +/// Category passed to the presign endpoint. Matches the Go server's +/// `UploadCategory` constants in `internal/models/pending_upload.go`. +enum UploadCategory: String { + case completion = "completion" + case documentImage = "document_image" + case documentFile = "document_file" +} + +/// Presigned-URL upload helper. Stateless — instantiate freely. +/// +/// Concurrency: each `upload(...)` call runs to completion sequentially. +/// For multiple images the caller can run several uploads in parallel via +/// `withTaskGroup`; the server's per-user concurrency cap (10 in-flight +/// presigns) is enforced server-side. +final class PresignedUploader { + + /// API base URL — read from KMP's ApiConfig so iOS and Android stay + /// in sync (LOCAL vs DEV vs PROD without divergent constants). + private let apiBaseURL: String + + /// Bearer token. Read once at init; if the user re-auths mid-session, + /// the caller should construct a fresh PresignedUploader. + private let authToken: String + + private let session: URLSession + + init?(session: URLSession = .shared) { + // ApiConfig.shared.getBaseUrl() resolves Environment (LOCAL/DEV/PROD). + // DataManager.shared.authToken is a StateFlow — read the + // current value via .value (SKIE-exposed property). + let baseUrl = ApiConfig.shared.getBaseUrl() + guard let token = DataManager.shared.authToken.value as String? else { + return nil + } + self.apiBaseURL = baseUrl + self.authToken = token + self.session = session + } + + /// Upload `data` to B2 in the named category. Returns the + /// pending_uploads.id the caller passes via `upload_ids[]` to attach + /// the object to a real entity. + func upload( + data: Data, + category: UploadCategory, + contentType: String = "image/jpeg", + fileName: String = "image.jpg" + ) async throws -> Int32 { + // Step 1: presign + let presigned = try await requestPresign( + category: category, + contentType: contentType, + contentLength: Int64(data.count) + ) + + // Step 2: direct POST to B2 + try await postToStorage( + uploadURL: presigned.uploadUrl, + fields: presigned.fields, + data: data, + contentType: contentType, + fileName: fileName + ) + + return Int32(presigned.id) + } + + /// Upload several images in parallel, returning their upload_ids in + /// input order. Stops at the first failure and surfaces it. + func uploadAll( + items: [(Data, String)], + category: UploadCategory, + contentType: String = "image/jpeg" + ) async throws -> [Int32] { + try await withThrowingTaskGroup(of: (Int, Int32).self) { group in + for (idx, item) in items.enumerated() { + let (data, name) = item + group.addTask { [self] in + let id = try await upload( + data: data, + category: category, + contentType: contentType, + fileName: name + ) + return (idx, id) + } + } + var pairs: [(Int, Int32)] = [] + for try await pair in group { + pairs.append(pair) + } + return pairs.sorted { $0.0 < $1.0 }.map { $0.1 } + } + } + + // MARK: - Step 1: presign + + private struct PresignBody: Encodable { + let category: String + let content_type: String + let content_length: Int64 + } + + private struct PresignResponse: Decodable { + let id: Int + let upload_url: String + let fields: [String: String] + let key: String + let expires_at: String + + // Map snake_case to nicer Swift names at the call site. + var uploadUrl: String { upload_url } + } + + private func requestPresign( + category: UploadCategory, + contentType: String, + contentLength: Int64 + ) async throws -> PresignResponse { + guard var url = URL(string: apiBaseURL) else { + throw PresignedUploaderError.presignFailed(status: 0, body: "invalid base url") + } + url.appendPathComponent("uploads/presign/") + + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization") + req.httpBody = try JSONEncoder().encode(PresignBody( + category: category.rawValue, + content_type: contentType, + content_length: contentLength + )) + + let (body, response): (Data, URLResponse) + do { + (body, response) = try await session.data(for: req) + } catch { + throw PresignedUploaderError.sessionError(error) + } + guard let http = response as? HTTPURLResponse else { + throw PresignedUploaderError.presignFailed(status: 0, body: "no response") + } + guard (200..<300).contains(http.statusCode) else { + throw PresignedUploaderError.presignFailed( + status: http.statusCode, + body: String(data: body, encoding: .utf8) ?? "" + ) + } + do { + return try JSONDecoder().decode(PresignResponse.self, from: body) + } catch { + throw PresignedUploaderError.presignFailed(status: http.statusCode, body: "decode failed: \(error)") + } + } + + // MARK: - Step 2: POST to B2 + + private func postToStorage( + uploadURL: String, + fields: [String: String], + data: Data, + contentType: String, + fileName: String + ) async throws { + guard let url = URL(string: uploadURL) else { + throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url") + } + + // Build a multipart/form-data body with all policy fields followed + // by a single "file" part (S3 POST policy mandates the file part + // come last). + let boundary = "Boundary-\(UUID().uuidString)" + var body = Data() + let crlf = "\r\n" + let appendString: (String) -> Void = { s in + body.append(s.data(using: .utf8) ?? Data()) + } + + // Stable order: ensure "key" and "Content-Type" appear before the + // file part so the policy signature validates. Unspecified order + // for the rest — S3 accepts any. + let orderedKeys = ["key", "Content-Type", "policy", "x-amz-algorithm", + "x-amz-credential", "x-amz-date", "x-amz-signature", + "x-amz-meta-uid"] + var emitted = Set() + for k in orderedKeys { + if let v = fields[k] { + appendString("--\(boundary)\(crlf)") + appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)") + appendString(v) + appendString(crlf) + emitted.insert(k) + } + } + for (k, v) in fields where !emitted.contains(k) { + appendString("--\(boundary)\(crlf)") + appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)") + appendString(v) + appendString(crlf) + } + + // file part — must be last + appendString("--\(boundary)\(crlf)") + appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\(crlf)") + appendString("Content-Type: \(contentType)\(crlf)\(crlf)") + body.append(data) + appendString(crlf) + appendString("--\(boundary)--\(crlf)") + + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + req.httpBody = body + + let (respBody, response): (Data, URLResponse) + do { + (respBody, response) = try await session.data(for: req) + } catch { + throw PresignedUploaderError.sessionError(error) + } + guard let http = response as? HTTPURLResponse else { + throw PresignedUploaderError.uploadFailed(status: 0, body: "no response") + } + guard (200..<300).contains(http.statusCode) else { + throw PresignedUploaderError.uploadFailed( + status: http.statusCode, + body: String(data: respBody, encoding: .utf8) ?? "" + ) + } + } +} diff --git a/iosApp/iosApp/Helpers/WidgetActionProcessor.swift b/iosApp/iosApp/Helpers/WidgetActionProcessor.swift index b3536a0..0e2f509 100644 --- a/iosApp/iosApp/Helpers/WidgetActionProcessor.swift +++ b/iosApp/iosApp/Helpers/WidgetActionProcessor.swift @@ -64,7 +64,8 @@ final class WidgetActionProcessor { notes: "Completed from widget", actualCost: nil, rating: nil, - imageUrls: nil + imageUrls: nil, + uploadIds: nil ) let result = try await APILayer.shared.createTaskCompletion(request: request) diff --git a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift index 6a2191c..2c65e60 100644 --- a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift +++ b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift @@ -388,7 +388,8 @@ class PushNotificationManager: NSObject, ObservableObject { notes: nil, actualCost: nil, rating: nil, - imageUrls: nil + imageUrls: nil, + uploadIds: nil ) let result = try await APILayer.shared.createTaskCompletion(request: request) diff --git a/iosApp/iosApp/Task/CompleteTaskView.swift b/iosApp/iosApp/Task/CompleteTaskView.swift index bca4bdc..2afb50d 100644 --- a/iosApp/iosApp/Task/CompleteTaskView.swift +++ b/iosApp/iosApp/Task/CompleteTaskView.swift @@ -337,51 +337,121 @@ struct CompleteTaskView: View { isSubmitting = true - // Create request with simplified Go API format - // Note: completedAt defaults to now on server if not provided - let request = TaskCompletionCreateRequest( - taskId: task.id, - completedAt: nil, - notes: notes.isEmpty ? nil : notes, - actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0), - rating: KotlinInt(int: Int32(rating)), - imageUrls: nil // Images uploaded separately and URLs added by handler - ) - - // Use TaskCompletionViewModel to create completion + // New direct-to-B2 upload path: downsample on-device, presign, POST + // straight to B2, pass the resulting upload_ids to the completion + // create call. Bytes never traverse our API server. See + // /api/uploads/presign in honeyDueAPI-go. if !selectedImages.isEmpty { - // Convert images to ImageData for Kotlin - let imageDataList = selectedImages.compactMap { uiImage -> ComposeApp.ImageData? in - guard let jpegData = uiImage.jpegData(compressionQuality: 0.8) else { return nil } - let byteArray = KotlinByteArray(data: jpegData) - return ComposeApp.ImageData(bytes: byteArray, fileName: "completion_image.jpg") - } - completionViewModel.createTaskCompletionWithImages(request: request, images: imageDataList) + uploadAndCreate() } else { + // No images — go straight to the completion create. + let request = TaskCompletionCreateRequest( + taskId: task.id, + completedAt: nil, + notes: notes.isEmpty ? nil : notes, + actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0), + rating: KotlinInt(int: Int32(rating)), + imageUrls: nil, + uploadIds: nil + ) completionViewModel.createTaskCompletion(request: request) + observeCompletionState() } + } - // Observe the result — store the Task so it can be cancelled on dismiss + /// Async pipeline: downsample → presign+upload to B2 → create completion + /// with the returned upload_ids. Errors at any stage become a single + /// alert; partial uploads (1 of 3 succeeded) currently fail the whole + /// flow — server-side cleanup reaps the orphans within the hour. + private func uploadAndCreate() { observationTask?.cancel() observationTask = Task { - for await state in completionViewModel.createCompletionState { - if Task.isCancelled { break } + // Step 1: downsample each image. Runs on the calling task; the + // ImageDownsampler is memory-bounded so this is safe for the + // expected batch sizes (≤5 images). + let payloads: [(Data, String)] = selectedImages.compactMap { uiImage -> (Data, String)? in + guard let data = ImageDownsampler.downsample(uiImage: uiImage, profile: .completion) else { + return nil + } + return (data, "completion_\(UUID().uuidString).jpg") + } + guard payloads.count == selectedImages.count else { await MainActor.run { - if let success = state as? ApiResultSuccess { - self.isSubmitting = false - self.onComplete(success.data?.updatedTask) // Pass back updated task - self.dismiss() - } else if let error = ApiResultBridge.error(from: state) { - self.errorMessage = error.message - self.showError = true - self.isSubmitting = false - } + errorMessage = "One or more photos couldn't be processed." + showError = true + isSubmitting = false } + return + } - // Break out of loop on terminal states - if state is ApiResultSuccess || ApiResultBridge.isError(state) { - break + // Step 2: presign + upload each to B2. PresignedUploader runs + // them in parallel under a server-enforced concurrency cap of 10. + guard let uploader = PresignedUploader() else { + await MainActor.run { + errorMessage = "Not authenticated" + showError = true + isSubmitting = false } + return + } + let uploadIds: [Int32] + do { + uploadIds = try await uploader.uploadAll(items: payloads, category: .completion) + } catch { + await MainActor.run { + errorMessage = (error as? PresignedUploaderError)?.errorDescription + ?? error.localizedDescription + showError = true + isSubmitting = false + } + return + } + + // Step 3: create completion via the existing endpoint, passing + // upload_ids so the server claims the pending_uploads rows and + // turns them into TaskCompletionImage rows. + let request = TaskCompletionCreateRequest( + taskId: task.id, + completedAt: nil, + notes: notes.isEmpty ? nil : notes, + actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0), + rating: KotlinInt(int: Int32(rating)), + imageUrls: nil, + uploadIds: uploadIds.map { KotlinInt(int: $0) } + ) + await MainActor.run { + completionViewModel.createTaskCompletion(request: request) + } + await observeCompletionStateAsync() + } + } + + /// Observe the createCompletionState StateFlow until a terminal value + /// arrives, then dismiss or surface an error. Called from the + /// no-images path. + private func observeCompletionState() { + observationTask?.cancel() + observationTask = Task { + await observeCompletionStateAsync() + } + } + + private func observeCompletionStateAsync() async { + for await state in completionViewModel.createCompletionState { + if Task.isCancelled { break } + await MainActor.run { + if let success = state as? ApiResultSuccess { + self.isSubmitting = false + self.onComplete(success.data?.updatedTask) + self.dismiss() + } else if let error = ApiResultBridge.error(from: state) { + self.errorMessage = error.message + self.showError = true + self.isSubmitting = false + } + } + if state is ApiResultSuccess || ApiResultBridge.isError(state) { + break } } }