Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23f4d70ac1 | |||
| fdcf82757d | |||
| 3890dd6f52 | |||
| d5041492a9 | |||
| ec5d93efab |
@@ -34,15 +34,20 @@ data class PresignUploadRequest(
|
||||
/**
|
||||
* 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.
|
||||
* The client makes one PUT request to [uploadUrl] with the raw object
|
||||
* bytes as the body and [headers] as the request headers. On success,
|
||||
* pass [id] back in the upload_ids[] field of the next
|
||||
* /api/task-completions/ or /api/documents/ create call.
|
||||
*
|
||||
* PUT (not POST) because B2's S3-compatible endpoint does not implement
|
||||
* the S3 POST Object form upload (returns HTTP 501).
|
||||
*/
|
||||
@Serializable
|
||||
data class PresignUploadResponse(
|
||||
val id: Int,
|
||||
@SerialName("upload_url") val uploadUrl: String,
|
||||
val fields: Map<String, String>,
|
||||
val method: String = "PUT",
|
||||
val headers: Map<String, String> = emptyMap(),
|
||||
val key: String,
|
||||
@SerialName("expires_at") val expiresAt: String
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ package com.tt.honeyDue.network
|
||||
*/
|
||||
object ApiConfig {
|
||||
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
||||
val CURRENT_ENV = Environment.LOCAL
|
||||
val CURRENT_ENV = Environment.PROD
|
||||
|
||||
enum class Environment {
|
||||
LOCAL,
|
||||
|
||||
@@ -5,7 +5,6 @@ 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.*
|
||||
@@ -14,17 +13,16 @@ 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.
|
||||
* signed PUT URL plus the headers the client must send.
|
||||
* Step 2: [putToStorage] — single PUT 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.
|
||||
* iOS uses its own native equivalent (PresignedUploader.swift). Both paths
|
||||
* use PUT because B2's S3-compatible endpoint does not implement the S3
|
||||
* POST Object form upload (returns HTTP 501 for any POST).
|
||||
*/
|
||||
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
private val baseUrl = ApiClient.getBaseUrl()
|
||||
@@ -61,38 +59,36 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 — POST `data` directly to B2 using the signed policy fields.
|
||||
* Step 2 — PUT `data` directly to B2 using the signed URL + headers.
|
||||
*
|
||||
* 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.
|
||||
* The presign signature binds the headers exactly, so we send them
|
||||
* verbatim. Content-Length is filled in automatically by Ktor from
|
||||
* the body size, but we still pass through Content-Type which Ktor
|
||||
* would otherwise default to application/octet-stream.
|
||||
*/
|
||||
suspend fun postToStorage(
|
||||
suspend fun putToStorage(
|
||||
uploadUrl: String,
|
||||
fields: Map<String, String>,
|
||||
headers: 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.put(uploadUrl) {
|
||||
// Apply server-supplied headers verbatim. Skip Content-Length
|
||||
// — Ktor sets it automatically from the body and will refuse
|
||||
// a manual override on most engines.
|
||||
headers.forEach { (k, v) ->
|
||||
if (!k.equals("Content-Length", ignoreCase = true)) {
|
||||
header(k, v)
|
||||
}
|
||||
}
|
||||
// Defensive: ensure Content-Type is set even if the server
|
||||
// omits it. The signed value (if present) takes precedence.
|
||||
if (!headers.keys.any { it.equals("Content-Type", ignoreCase = true) }) {
|
||||
header(HttpHeaders.ContentType, contentType)
|
||||
}
|
||||
setBody(data)
|
||||
}
|
||||
val response = client.submitFormWithBinaryData(
|
||||
url = uploadUrl,
|
||||
formData = parts,
|
||||
)
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
@@ -124,7 +120,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
category: String,
|
||||
contentType: String,
|
||||
data: ByteArray,
|
||||
fileName: String,
|
||||
@Suppress("UNUSED_PARAMETER") fileName: String,
|
||||
): ApiResult<Int> {
|
||||
val presignResult = presign(token, category, contentType, data.size.toLong())
|
||||
val presigned = (presignResult as? ApiResult.Success)?.data
|
||||
@@ -133,16 +129,15 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
(presignResult as? ApiResult.Error)?.code,
|
||||
)
|
||||
|
||||
val postResult = postToStorage(
|
||||
val putResult = putToStorage(
|
||||
uploadUrl = presigned.uploadUrl,
|
||||
fields = presigned.fields,
|
||||
headers = presigned.headers,
|
||||
data = data,
|
||||
contentType = contentType,
|
||||
fileName = fileName,
|
||||
)
|
||||
return when (postResult) {
|
||||
return when (putResult) {
|
||||
is ApiResult.Success -> ApiResult.Success(presigned.id)
|
||||
is ApiResult.Error -> postResult
|
||||
is ApiResult.Error -> putResult
|
||||
else -> ApiResult.Error("Upload failed in unknown state")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,23 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// When `true`, every test in the suite forces a logout → login cycle
|
||||
/// in `setUp`, guaranteeing a freshly-issued auth token on each run.
|
||||
///
|
||||
/// Default is `false`: tests reuse the existing logged-in session
|
||||
/// from the previous test in the same suite — much faster (one login
|
||||
/// per suite, not one per test) and resilient to suites where the
|
||||
/// current screen has no logout affordance (`UITestHelpers.ensureLoggedOut`
|
||||
/// times out → the test fails before its body runs).
|
||||
///
|
||||
/// Override to `true` in suites that have observed transient
|
||||
/// `Invalid token` 401s on POST/PATCH while reads continue to work.
|
||||
/// The recipe was added after a 2026-05 incident where the API
|
||||
/// container was rebuilt mid-suite and in-memory tokens went stale.
|
||||
/// In normal CI runs against a stable API + freshly-erased simulator,
|
||||
/// session reuse is the correct default.
|
||||
var forceFreshLoginPerTest: Bool { false }
|
||||
|
||||
override func setUpWithError() throws {
|
||||
guard TestAccountAPIClient.isBackendReachable() else {
|
||||
throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)")
|
||||
@@ -41,27 +58,27 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
|
||||
try super.setUpWithError()
|
||||
|
||||
// If already logged in (tab bar visible), skip the login flow
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
if tabBar.waitForExistence(timeout: defaultTimeout) {
|
||||
// Already logged in — just set up API session if needed
|
||||
if needsAPISession {
|
||||
guard let apiSession = TestAccountManager.loginSeededAccount(
|
||||
username: apiCredentials.username,
|
||||
password: apiCredentials.password
|
||||
) else {
|
||||
XCTFail("Could not login API account '\(apiCredentials.username)'")
|
||||
return
|
||||
}
|
||||
session = apiSession
|
||||
cleaner = TestDataCleaner(token: apiSession.token)
|
||||
}
|
||||
return
|
||||
}
|
||||
let alreadyLoggedIn = tabBar.waitForExistence(timeout: defaultTimeout)
|
||||
|
||||
// Not logged in — do the full login flow
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
loginToMainApp()
|
||||
// Force-fresh path: log out (if needed) and re-authenticate per
|
||||
// test so every test starts with a freshly-issued JWT. Catches
|
||||
// server-side token invalidation that would otherwise surface
|
||||
// mid-suite as opaque 401s on the first mutation call.
|
||||
if forceFreshLoginPerTest {
|
||||
if alreadyLoggedIn {
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
} else {
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
}
|
||||
loginToMainApp()
|
||||
} else if !alreadyLoggedIn {
|
||||
// Legacy session-reuse path: only log in when not already in.
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
loginToMainApp()
|
||||
}
|
||||
// (When `forceFreshLoginPerTest == false` AND we're already
|
||||
// logged in, fall through with the existing session.)
|
||||
|
||||
if needsAPISession {
|
||||
guard let apiSession = TestAccountManager.loginSeededAccount(
|
||||
|
||||
@@ -4,15 +4,18 @@ 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.
|
||||
/// 1. POST /api/uploads/presign → server returns a signed PUT URL plus
|
||||
/// the headers (Content-Type, Content-Length) the client must send.
|
||||
/// The signature binds those headers — B2 rejects the upload if the
|
||||
/// bytes/headers don't match exactly.
|
||||
/// 2. PUT the bytes directly to B2, no API server in the data path.
|
||||
/// 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.
|
||||
///
|
||||
/// We use PUT (not POST) because B2's S3-compatible endpoint does not
|
||||
/// implement the S3 POST Object form upload — every POST returns HTTP 501.
|
||||
///
|
||||
/// All errors map to `PresignedUploaderError` — the Swift call site can
|
||||
/// translate to user-facing copy without parsing nested HTTP details.
|
||||
enum PresignedUploaderError: Error, LocalizedError {
|
||||
@@ -33,7 +36,7 @@ enum PresignedUploaderError: Error, LocalizedError {
|
||||
default: return "Couldn't start upload (server returned \(status))."
|
||||
}
|
||||
case .uploadFailed(let status, _):
|
||||
return "Upload failed (B2 returned \(status))."
|
||||
return "Upload failed (storage returned \(status))."
|
||||
case .sessionError(let err):
|
||||
return err.localizedDescription
|
||||
}
|
||||
@@ -95,13 +98,12 @@ final class PresignedUploader {
|
||||
contentLength: Int64(data.count)
|
||||
)
|
||||
|
||||
// Step 2: direct POST to B2
|
||||
try await postToStorage(
|
||||
// Step 2: direct PUT to B2
|
||||
try await putToStorage(
|
||||
uploadURL: presigned.uploadUrl,
|
||||
fields: presigned.fields,
|
||||
headers: presigned.headers,
|
||||
data: data,
|
||||
contentType: contentType,
|
||||
fileName: fileName
|
||||
contentType: contentType
|
||||
)
|
||||
|
||||
return Int32(presigned.id)
|
||||
@@ -146,7 +148,8 @@ final class PresignedUploader {
|
||||
private struct PresignResponse: Decodable {
|
||||
let id: Int
|
||||
let upload_url: String
|
||||
let fields: [String: String]
|
||||
let method: String?
|
||||
let headers: [String: String]
|
||||
let key: String
|
||||
let expires_at: String
|
||||
|
||||
@@ -196,64 +199,39 @@ final class PresignedUploader {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 2: POST to B2
|
||||
// MARK: - Step 2: PUT to B2
|
||||
//
|
||||
// The presign response includes the exact headers (Content-Type +
|
||||
// Content-Length) that were signed. Send them verbatim — any deviation
|
||||
// invalidates the signature and B2 will reject the upload.
|
||||
//
|
||||
// Content-Length is set automatically by URLSession from httpBody.count,
|
||||
// so we don't manually echo it back; we still send Content-Type because
|
||||
// URLSession will otherwise default it to application/x-www-form-urlencoded.
|
||||
|
||||
private func postToStorage(
|
||||
private func putToStorage(
|
||||
uploadURL: String,
|
||||
fields: [String: String],
|
||||
headers: [String: String],
|
||||
data: Data,
|
||||
contentType: String,
|
||||
fileName: String
|
||||
contentType: 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
|
||||
req.httpMethod = "PUT"
|
||||
req.httpBody = data
|
||||
|
||||
// Apply server-supplied headers verbatim. Skip Content-Length —
|
||||
// URLSession sets it automatically and will refuse to override it.
|
||||
for (k, v) in headers where k.lowercased() != "content-length" {
|
||||
req.setValue(v, forHTTPHeaderField: k)
|
||||
}
|
||||
// Defensive: ensure Content-Type is set even if the server omits it.
|
||||
if req.value(forHTTPHeaderField: "Content-Type") == nil {
|
||||
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
let (respBody, response): (Data, URLResponse)
|
||||
do {
|
||||
|
||||
@@ -120,7 +120,6 @@ struct CompleteTaskView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.keyboardDismissToolbar()
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField)
|
||||
} label: {
|
||||
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
|
||||
@@ -142,7 +141,6 @@ struct CompleteTaskView: View {
|
||||
TextEditor(text: $notes)
|
||||
.frame(minHeight: 100)
|
||||
.scrollContentBackground(.hidden)
|
||||
.keyboardDismissToolbar()
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField)
|
||||
}
|
||||
} footer: {
|
||||
@@ -289,6 +287,12 @@ struct CompleteTaskView: View {
|
||||
.background(WarmGradientBackground())
|
||||
.navigationTitle(L10n.Tasks.completeTask)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
// ONE keyboard "Done" toolbar at the form root — per-field
|
||||
// `.keyboardDismissToolbar()` modifiers each install a
|
||||
// separate `ToolbarItemGroup(placement: .keyboard)`, and
|
||||
// SwiftUI stacks them on the responder chain so any focused
|
||||
// field renders multiple Done buttons side-by-side (issue #5).
|
||||
.keyboardDismissToolbar()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.Common.cancel) {
|
||||
|
||||
Reference in New Issue
Block a user