3cd115a436
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>
275 lines
10 KiB
Swift
275 lines
10 KiB
Swift
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<String?> — 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<String>()
|
|
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) ?? ""
|
|
)
|
|
}
|
|
}
|
|
}
|