feat(uploads): direct-to-B2 presigned image upload (iOS + Android) #3
Reference in New Issue
Block a user
Delete Branch "feat/presigned-uploads"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Switches mobile image uploads from multipart-via-API to direct-to-B2 presigned uploads. Bytes never traverse our API server; B2 enforces a 10 MB cap at the protocol level via the signed POST policy's
content-length-rangecondition.Pairs with honeyDueAPI commits
29c9014+b7f8329+1402625which are already deployed live onhoneydue-api:b7f8329. The newPOST /api/uploads/presignendpoint is serving traffic right now — this PR is the client side.Commits
What changed
iOS (Swift) — primary path
Helpers/ImageDownsampler.swift(new):CGImageSourceCreateThumbnailAtIndex-based resize. Pays only the cost of the resized bitmap — a 12 MP iPhone photo previously decompressed to ~50 MB regardless of JPEG size. Profiles:completion(2048 px / quality 0.85),documentImage(2560 px / 0.90).Helpers/PresignedUploader.swift(new): three-step orchestration (POST /uploads/presign→ multipart POST direct to B2 with the signed policy fields → returnupload_id). Concurrent uploads viaTaskGroup. HTTP error → user-facing copy mapping.Task/CompleteTaskView.swift: replacesuiImage.jpegData(compressionQuality: 0.8)+ multipart with downsample → upload-to-B2 → create-completion-with-upload_ids[]. The no-image branch unchanged.WidgetActionProcessor.swift/PushNotificationManager.swift: drop legacyimageUrls: nilargument.Android (Kotlin) — parity
composeApp/.../media/ImageDownsampler.kt(new):BitmapFactory.Options.inSampleSize+ proportional scale + JPEG compress. Same profiles as iOS.composeApp/.../network/UploadApi.kt(new): Ktor-based presign + direct-to-B2 POST. Preserves form-field order so the S3 policy signature validates.TaskCompletionViewModel.createTaskCompletionWithImagesrewired internally: signature unchanged, transport replaced. The three Android UI call sites (TasksScreen,AllTasksScreen,ResidenceDetailScreen, plusCompleteTaskDialog/CompleteTaskScreen) need no changes.NotificationActionReceiver(×2): dropimageUrls = nullarg.Shared (Kotlin)
models/TaskCompletion.kt: droppedimageUrls: List<String>?; addeduploadIds: List<Int>?. AddedPresignUploadRequest/PresignUploadResponsematching the Go API DTOs.network/APILayer.kt: droppedcreateTaskCompletionWithImages(legacy multipart helper); addeduploadImage(category, contentType, bytes, fileName) → upload_id.network/TaskCompletionApi.kt: droppedcreateCompletionWithImagesKtor multipart helper.Verification
compileDebugKotlinAndroid— BUILD SUCCESSFULcompileKotlinIosArm64— BUILD SUCCESSFULxcodebuild -scheme HoneyDue— BUILD SUCCEEDEDServer state (already live)
POST /api/uploads/presign✓ deployed30 * * * *registered, B2 storage init confirmed in worker logs ✓pending_uploadstable000002✓Outstanding manual step
B2 bucket lifecycle rule still needs to be applied via the Backblaze console — see
deploy-k3s/manifests/b2-lifecycle.mdin honeyDueAPI. One-time configuration on buckethoneyDueProd, prefixuploads/: hide after 7 days, delete 1 day after hidden. This is a backstop only — the worker's hourly cron is the primary reaper.Notes
gitea/master(49e2397) and contains only the upload work. It does not include the prior in-flight task-cache-unification work that was on the originalfix/task-cache-unificationbranch — that branch and its conflicts withrc/android-ios-parityare unrelated to this PR.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>The KMP shared layer's task-completion-with-images path now exclusively uses the presigned-URL flow: each image is compressed, uploaded directly to B2 via APILayer.uploadImage, and the resulting upload_ids are passed to /api/task-completions/ as JSON. Bytes never traverse our API server. Changes: - TaskCompletionViewModel.createTaskCompletionWithImages now does the presign→POST→collect-ids dance internally. The signature stays the same so the three Android UI call sites (TasksScreen, AllTasksScreen, ResidenceDetailScreen, CompleteTaskDialog, CompleteTaskScreen) need no changes. - APILayer.createTaskCompletionWithImages removed (dead). - TaskCompletionApi.createCompletionWithImages removed (the multipart HTTP helper that posted to the legacy POST /api/task-completions/ multipart endpoint). - TaskCompletionCreateRequest.imageUrls field removed. - Three Swift call sites (CompleteTaskView, WidgetActionProcessor, PushNotificationManager) updated to drop the imageUrls argument. - Two Kotlin call sites (CompleteTaskDialog, CompleteTaskScreen) updated. Image uploads now match WhatsApp/Slack-class architecture: client-side compression + direct-to-storage upload + lightweight JSON entity create. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>