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
15 changed files with 843 additions and 127 deletions
@@ -91,7 +91,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
notes = null, notes = null,
actualCost = null, actualCost = null,
rating = null, rating = null,
imageUrls = null
) )
when (val result = APILayer.createTaskCompletion(request)) { when (val result = APILayer.createTaskCompletion(request)) {
@@ -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)
}
}
@@ -105,7 +105,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
notes = "Completed from notification", notes = "Completed from notification",
actualCost = null, actualCost = null,
rating = null, rating = null,
imageUrls = null
) )
when (val result = APILayer.createTaskCompletion(request)) { when (val result = APILayer.createTaskCompletion(request)) {
is ApiResult.Success -> { is ApiResult.Success -> {
@@ -13,6 +13,37 @@ data class TaskCompletionCreateRequest(
val notes: String? = null, val notes: String? = null,
@SerialName("actual_cost") val actualCost: Double? = null, @SerialName("actual_cost") val actualCost: Double? = null,
val rating: Int? = null, // 1-5 star rating val rating: Int? = null, // 1-5 star rating
@SerialName("image_urls") val imageUrls: List<String>? = null // Multiple image URLs @SerialName("upload_ids") val uploadIds: List<Int>? = null // 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 notificationApi = NotificationApi()
private val subscriptionApi = SubscriptionApi() private val subscriptionApi = SubscriptionApi()
private val taskTemplateApi = TaskTemplateApi() private val taskTemplateApi = TaskTemplateApi()
private val uploadApi = UploadApi()
// ==================== Initialization Guards ==================== // ==================== Initialization Guards ====================
@@ -815,30 +816,6 @@ object APILayer {
} }
} }
suspend fun createTaskCompletionWithImages(
request: TaskCompletionCreateRequest,
images: List<ByteArray>,
imageFileNames: List<String>
): ApiResult<TaskCompletionResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskCompletionApi.createCompletionWithImages(token, request, images, imageFileNames)
if (result is ApiResult.Success) {
// Update summary from response - eliminates need for separate getSummary call
DataManager.setTotalSummary(result.data.summary)
// The response includes the updated task, update it in DataManager
result.data.data.updatedTask?.let { updatedTask ->
DataManager.updateTask(updatedTask)
}
return ApiResult.Success(result.data.data)
}
return when (result) {
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
}
/** /**
* Get all completions for a specific task * Get all completions for a specific task
*/ */
@@ -1416,6 +1393,42 @@ object APILayer {
return result 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 ==================== // ==================== Notification Operations ====================
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> { suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {
@@ -94,47 +94,4 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
} }
} }
suspend fun createCompletionWithImages(
token: String,
request: TaskCompletionCreateRequest,
images: List<ByteArray> = emptyList(),
imageFileNames: List<String> = emptyList()
): ApiResult<WithSummaryResponse<TaskCompletionResponse>> {
return try {
val response = client.submitFormWithBinaryData(
url = "$baseUrl/task-completions/",
formData = formData {
// Add text fields
append("task_id", request.taskId.toString())
request.completedAt?.let { append("completed_at", it) }
request.actualCost?.let { append("actual_cost", it.toString()) }
request.notes?.let { append("notes", it) }
request.rating?.let { append("rating", it.toString()) }
// Add image files
images.forEachIndexed { index, imageBytes ->
val fileName = imageFileNames.getOrNull(index) ?: "image_$index.jpg"
append(
"images",
imageBytes,
Headers.build {
append(HttpHeaders.ContentType, "image/jpeg")
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
}
)
}
}
) {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to create completion with images", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
} }
@@ -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")
}
}
}
@@ -377,7 +377,8 @@ fun CompleteTaskDialog(
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(), actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor, notes = notesWithContractor,
rating = rating, rating = rating,
imageUrls = null // Images uploaded separately and URLs added by handler // upload_ids populated by the ViewModel after each
// image is uploaded directly to B2.
), ),
selectedImages selectedImages
) )
@@ -421,7 +421,8 @@ fun CompleteTaskScreen(
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(), actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor, notes = notesWithContractor,
rating = rating, rating = rating,
imageUrls = null // upload_ids populated by the ViewModel after each
// image is uploaded directly to B2.
), ),
selectedImages selectedImages
) )
@@ -25,38 +25,61 @@ class TaskCompletionViewModel : ViewModel() {
} }
/** /**
* Create task completion with images. * Create task completion with images, using the presigned-URL upload flow.
*
* For each image: compress, presign + POST direct to B2, collect the
* upload_id. Once all uploads succeed, create the completion with the
* collected upload_ids in a single JSON request. Bytes never traverse
* our API server.
*
* If any individual upload fails, the whole batch fails — partial
* pending_uploads rows are reaped server-side by the hourly cleanup
* cron, so there's nothing to clean up client-side.
* *
* @param request The completion request data * @param request The completion request data
* @param images List of ImageData (from platform-specific image pickers) * @param images List of ImageData (from platform-specific image pickers)
*/ */
fun createTaskCompletionWithImages( fun createTaskCompletionWithImages(
request: TaskCompletionCreateRequest, request: TaskCompletionCreateRequest,
images: List<com.tt.honeyDue.platform.ImageData> = emptyList() images: List<com.tt.honeyDue.platform.ImageData> = emptyList(),
) { ) {
viewModelScope.launch { viewModelScope.launch {
_createCompletionState.value = ApiResult.Loading _createCompletionState.value = ApiResult.Loading
// Compress images and prepare for upload val uploadIds = mutableListOf<Int>()
val compressedImages = images.map { ImageCompressor.compressImage(it) } for ((index, image) in images.withIndex()) {
val imageFileNames = images.mapIndexed { index, image -> val compressed = ImageCompressor.compressImage(image)
// Always use .jpg extension since we compress to JPEG val fileName = run {
val baseName = image.fileName.ifBlank { "completion_$index" } val base = image.fileName.ifBlank { "completion_$index" }
if (baseName.endsWith(".jpg", ignoreCase = true) || if (base.endsWith(".jpg", ignoreCase = true) ||
baseName.endsWith(".jpeg", ignoreCase = true)) { base.endsWith(".jpeg", ignoreCase = true)
baseName ) base else base.substringBeforeLast('.', base) + ".jpg"
} else { }
// Remove any existing extension and add .jpg val uploadResult = APILayer.uploadImage(
baseName.substringBeforeLast('.', baseName) + ".jpg" category = "completion",
contentType = "image/jpeg",
bytes = compressed,
fileName = fileName,
)
when (uploadResult) {
is ApiResult.Success -> uploadIds += uploadResult.data
is ApiResult.Error -> {
_createCompletionState.value = ApiResult.Error(uploadResult.message, uploadResult.code)
return@launch
}
else -> {
_createCompletionState.value = ApiResult.Error("Upload failed in unexpected state")
return@launch
}
} }
} }
// Use APILayer which handles DataManager updates and summary refresh val withUploads = if (uploadIds.isNotEmpty()) {
_createCompletionState.value = APILayer.createTaskCompletionWithImages( request.copy(uploadIds = uploadIds.toList())
request = request, } else {
images = compressedImages, request
imageFileNames = imageFileNames }
) _createCompletionState.value = APILayer.createTaskCompletion(withUploads)
} }
} }
@@ -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)
}
}
@@ -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<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) ?? ""
)
}
}
}
@@ -64,7 +64,7 @@ final class WidgetActionProcessor {
notes: "Completed from widget", notes: "Completed from widget",
actualCost: nil, actualCost: nil,
rating: nil, rating: nil,
imageUrls: nil uploadIds: nil
) )
let result = try await APILayer.shared.createTaskCompletion(request: request) let result = try await APILayer.shared.createTaskCompletion(request: request)
@@ -388,7 +388,7 @@ class PushNotificationManager: NSObject, ObservableObject {
notes: nil, notes: nil,
actualCost: nil, actualCost: nil,
rating: nil, rating: nil,
imageUrls: nil uploadIds: nil
) )
let result = try await APILayer.shared.createTaskCompletion(request: request) let result = try await APILayer.shared.createTaskCompletion(request: request)
+102 -34
View File
@@ -337,51 +337,119 @@ struct CompleteTaskView: View {
isSubmitting = true isSubmitting = true
// Create request with simplified Go API format // New direct-to-B2 upload path: downsample on-device, presign, POST
// Note: completedAt defaults to now on server if not provided // straight to B2, pass the resulting upload_ids to the completion
let request = TaskCompletionCreateRequest( // create call. Bytes never traverse our API server. See
taskId: task.id, // /api/uploads/presign in honeyDueAPI-go.
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
if !selectedImages.isEmpty { if !selectedImages.isEmpty {
// Convert images to ImageData for Kotlin uploadAndCreate()
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)
} else { } 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)),
uploadIds: nil
)
completionViewModel.createTaskCompletion(request: request) 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?.cancel()
observationTask = Task { observationTask = Task {
for await state in completionViewModel.createCompletionState { // Step 1: downsample each image. Runs on the calling task; the
if Task.isCancelled { break } // 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 { await MainActor.run {
if let success = state as? ApiResultSuccess<TaskCompletionResponse> { errorMessage = "One or more photos couldn't be processed."
self.isSubmitting = false showError = true
self.onComplete(success.data?.updatedTask) // Pass back updated task isSubmitting = false
self.dismiss()
} else if let error = ApiResultBridge.error(from: state) {
self.errorMessage = error.message
self.showError = true
self.isSubmitting = false
}
} }
return
}
// Break out of loop on terminal states // Step 2: presign + upload each to B2. PresignedUploader runs
if state is ApiResultSuccess<TaskCompletionResponse> || ApiResultBridge.isError(state) { // them in parallel under a server-enforced concurrency cap of 10.
break 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)),
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<TaskCompletionResponse> {
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<TaskCompletionResponse> || ApiResultBridge.isError(state) {
break
} }
} }
} }