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
11 changed files with 47 additions and 96 deletions
Showing only changes of commit b2d03ef8b2 - Show all commits
@@ -91,7 +91,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
notes = null,
actualCost = null,
rating = null,
imageUrls = null
)
when (val result = APILayer.createTaskCompletion(request)) {
@@ -105,7 +105,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
notes = "Completed from notification",
actualCost = null,
rating = null,
imageUrls = null
)
when (val result = APILayer.createTaskCompletion(request)) {
is ApiResult.Success -> {
@@ -13,8 +13,7 @@ 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<String>? = null, // Legacy: URLs returned by /api/uploads/* multipart endpoints
@SerialName("upload_ids") val uploadIds: List<Int>? = null // New: pending_uploads.id values from /api/uploads/presign + direct B2 POST
@SerialName("upload_ids") val uploadIds: List<Int>? = null // pending_uploads.id values from /api/uploads/presign + direct B2 POST
)
/**
@@ -816,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
*/
@@ -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")
}
}
}
@@ -377,7 +377,8 @@ fun CompleteTaskDialog(
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor,
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
)
@@ -421,7 +421,8 @@ fun CompleteTaskScreen(
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor,
rating = rating,
imageUrls = null
// upload_ids populated by the ViewModel after each
// image is uploaded directly to B2.
),
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 images List of ImageData (from platform-specific image pickers)
*/
fun createTaskCompletionWithImages(
request: TaskCompletionCreateRequest,
images: List<com.tt.honeyDue.platform.ImageData> = emptyList()
images: List<com.tt.honeyDue.platform.ImageData> = emptyList(),
) {
viewModelScope.launch {
_createCompletionState.value = ApiResult.Loading
// Compress images and prepare for upload
val compressedImages = images.map { ImageCompressor.compressImage(it) }
val imageFileNames = images.mapIndexed { index, image ->
// Always use .jpg extension since we compress to JPEG
val baseName = image.fileName.ifBlank { "completion_$index" }
if (baseName.endsWith(".jpg", ignoreCase = true) ||
baseName.endsWith(".jpeg", ignoreCase = true)) {
baseName
} else {
// Remove any existing extension and add .jpg
baseName.substringBeforeLast('.', baseName) + ".jpg"
val uploadIds = mutableListOf<Int>()
for ((index, image) in images.withIndex()) {
val compressed = ImageCompressor.compressImage(image)
val fileName = run {
val base = image.fileName.ifBlank { "completion_$index" }
if (base.endsWith(".jpg", ignoreCase = true) ||
base.endsWith(".jpeg", ignoreCase = true)
) base else base.substringBeforeLast('.', base) + ".jpg"
}
val uploadResult = APILayer.uploadImage(
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
_createCompletionState.value = APILayer.createTaskCompletionWithImages(
request = request,
images = compressedImages,
imageFileNames = imageFileNames
)
val withUploads = if (uploadIds.isNotEmpty()) {
request.copy(uploadIds = uploadIds.toList())
} else {
request
}
_createCompletionState.value = APILayer.createTaskCompletion(withUploads)
}
}
@@ -64,7 +64,6 @@ final class WidgetActionProcessor {
notes: "Completed from widget",
actualCost: nil,
rating: nil,
imageUrls: nil,
uploadIds: nil
)
@@ -388,7 +388,6 @@ class PushNotificationManager: NSObject, ObservableObject {
notes: nil,
actualCost: nil,
rating: nil,
imageUrls: nil,
uploadIds: nil
)
let result = try await APILayer.shared.createTaskCompletion(request: request)
@@ -351,7 +351,6 @@ struct CompleteTaskView: View {
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)
@@ -416,7 +415,6 @@ struct CompleteTaskView: View {
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 {