feat(uploads): direct-to-B2 presigned image upload (iOS + Android) #3

Merged
admin merged 2 commits from feat/presigned-uploads into master 2026-05-01 19:40:11 -05:00
Owner

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-range condition.

Pairs with honeyDueAPI commits 29c9014 + b7f8329 + 1402625 which are already deployed live on honeydue-api:b7f8329. The new POST /api/uploads/presign endpoint is serving traffic right now — this PR is the client side.

Commits

b2d03ef refactor(uploads): drop legacy multipart helpers; route Android
        UI through presigned flow
fa0ce30 feat(uploads): direct-to-B2 presigned image upload from iOS + Android

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 → return upload_id). Concurrent uploads via TaskGroup. HTTP error → user-facing copy mapping.
  • Task/CompleteTaskView.swift: replaces uiImage.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 legacy imageUrls: nil argument.

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.createTaskCompletionWithImages rewired internally: signature unchanged, transport replaced. The three Android UI call sites (TasksScreen, AllTasksScreen, ResidenceDetailScreen, plus CompleteTaskDialog / CompleteTaskScreen) need no changes.
  • NotificationActionReceiver (×2): drop imageUrls = null arg.

Shared (Kotlin)

  • models/TaskCompletion.kt: dropped imageUrls: List<String>?; added uploadIds: List<Int>?. Added PresignUploadRequest / PresignUploadResponse matching the Go API DTOs.
  • network/APILayer.kt: dropped createTaskCompletionWithImages (legacy multipart helper); added uploadImage(category, contentType, bytes, fileName) → upload_id.
  • network/TaskCompletionApi.kt: dropped createCompletionWithImages Ktor multipart helper.

Verification

  • Android Kotlin: compileDebugKotlinAndroid — BUILD SUCCESSFUL
  • iOS Kotlin framework: compileKotlinIosArm64 — BUILD SUCCESSFUL
  • iOS Swift app: xcodebuild -scheme HoneyDue — BUILD SUCCEEDED

Server state (already live)

New endpoint POST /api/uploads/presign ✓ deployed
Cleanup cron 30 * * * * registered, B2 storage init confirmed in worker logs ✓
pending_uploads table applied via goose migration 000002
Per-user policy 10 MB cap · 50 presigns/hr · 10 concurrent in-flight ✓

Outstanding manual step

B2 bucket lifecycle rule still needs to be applied via the Backblaze console — see deploy-k3s/manifests/b2-lifecycle.md in honeyDueAPI. One-time configuration on bucket honeyDueProd, prefix uploads/: hide after 7 days, delete 1 day after hidden. This is a backstop only — the worker's hourly cron is the primary reaper.

Notes

  • This branch was created off current 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 original fix/task-cache-unification branch — that branch and its conflicts with rc/android-ios-parity are unrelated to this PR.
## 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-range` condition. Pairs with [honeyDueAPI commits `29c9014` + `b7f8329` + `1402625`](https://gitea.treytartt.com/admin/honeyDueAPI/commits/branch/master) which are already deployed live on `honeydue-api:b7f8329`. The new `POST /api/uploads/presign` endpoint is serving traffic right now — this PR is the client side. ## Commits ``` b2d03ef refactor(uploads): drop legacy multipart helpers; route Android UI through presigned flow fa0ce30 feat(uploads): direct-to-B2 presigned image upload from iOS + Android ``` ## 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 → return `upload_id`). Concurrent uploads via `TaskGroup`. HTTP error → user-facing copy mapping. - **`Task/CompleteTaskView.swift`**: replaces `uiImage.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 legacy `imageUrls: nil` argument. ### 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.createTaskCompletionWithImages`** rewired internally: signature unchanged, transport replaced. The three Android UI call sites (`TasksScreen`, `AllTasksScreen`, `ResidenceDetailScreen`, plus `CompleteTaskDialog` / `CompleteTaskScreen`) need no changes. - **`NotificationActionReceiver`** (×2): drop `imageUrls = null` arg. ### Shared (Kotlin) - **`models/TaskCompletion.kt`**: dropped `imageUrls: List<String>?`; added `uploadIds: List<Int>?`. Added `PresignUploadRequest` / `PresignUploadResponse` matching the Go API DTOs. - **`network/APILayer.kt`**: dropped `createTaskCompletionWithImages` (legacy multipart helper); added `uploadImage(category, contentType, bytes, fileName) → upload_id`. - **`network/TaskCompletionApi.kt`**: dropped `createCompletionWithImages` Ktor multipart helper. ## Verification - Android Kotlin: `compileDebugKotlinAndroid` — BUILD SUCCESSFUL - iOS Kotlin framework: `compileKotlinIosArm64` — BUILD SUCCESSFUL - iOS Swift app: `xcodebuild -scheme HoneyDue` — BUILD SUCCEEDED ## Server state (already live) | | | |---|---| | New endpoint | `POST /api/uploads/presign` ✓ deployed | | Cleanup cron | `30 * * * *` registered, B2 storage init confirmed in worker logs ✓ | | `pending_uploads` table | applied via goose migration `000002` ✓ | | Per-user policy | 10 MB cap · 50 presigns/hr · 10 concurrent in-flight ✓ | ## Outstanding manual step **B2 bucket lifecycle rule** still needs to be applied via the Backblaze console — see `deploy-k3s/manifests/b2-lifecycle.md` in honeyDueAPI. One-time configuration on bucket `honeyDueProd`, prefix `uploads/`: hide after 7 days, delete 1 day after hidden. This is a backstop only — the worker's hourly cron is the primary reaper. ## Notes - This branch was created off current `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 original `fix/task-cache-unification` branch — that branch and its conflicts with `rc/android-ios-parity` are unrelated to this PR.
admin added 2 commits 2026-05-01 17:49:54 -05:00
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>
admin merged commit 2064e70d75 into master 2026-05-01 19:40:11 -05:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/honeyDueKMP#3