refactor(uploads): drop legacy multipart helpers; route Android UI through presigned flow
Android UI Tests / ui-tests (pull_request) Has been cancelled
Android UI Tests / ui-tests (pull_request) Has been cancelled
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>
This commit is contained in:
@@ -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)) {
|
||||||
|
|||||||
-1
@@ -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,8 +13,7 @@ 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, // Legacy: URLs returned by /api/uploads/* multipart endpoints
|
@SerialName("upload_ids") val uploadIds: List<Int>? = null // pending_uploads.id values from /api/uploads/presign + direct B2 POST
|
||||||
@SerialName("upload_ids") val uploadIds: List<Int>? = null // New: 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
|
* 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
+42
-19
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ final class WidgetActionProcessor {
|
|||||||
notes: "Completed from widget",
|
notes: "Completed from widget",
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
rating: nil,
|
rating: nil,
|
||||||
imageUrls: nil,
|
|
||||||
uploadIds: nil
|
uploadIds: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -388,7 +388,6 @@ class PushNotificationManager: NSObject, ObservableObject {
|
|||||||
notes: nil,
|
notes: nil,
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
rating: nil,
|
rating: nil,
|
||||||
imageUrls: nil,
|
|
||||||
uploadIds: nil
|
uploadIds: nil
|
||||||
)
|
)
|
||||||
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||||
|
|||||||
@@ -351,7 +351,6 @@ struct CompleteTaskView: View {
|
|||||||
notes: notes.isEmpty ? nil : notes,
|
notes: notes.isEmpty ? nil : notes,
|
||||||
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
|
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
|
||||||
rating: KotlinInt(int: Int32(rating)),
|
rating: KotlinInt(int: Int32(rating)),
|
||||||
imageUrls: nil,
|
|
||||||
uploadIds: nil
|
uploadIds: nil
|
||||||
)
|
)
|
||||||
completionViewModel.createTaskCompletion(request: request)
|
completionViewModel.createTaskCompletion(request: request)
|
||||||
@@ -416,7 +415,6 @@ struct CompleteTaskView: View {
|
|||||||
notes: notes.isEmpty ? nil : notes,
|
notes: notes.isEmpty ? nil : notes,
|
||||||
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
|
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
|
||||||
rating: KotlinInt(int: Int32(rating)),
|
rating: KotlinInt(int: Int32(rating)),
|
||||||
imageUrls: nil,
|
|
||||||
uploadIds: uploadIds.map { KotlinInt(int: $0) }
|
uploadIds: uploadIds.map { KotlinInt(int: $0) }
|
||||||
)
|
)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
Reference in New Issue
Block a user