5 Commits

Author SHA1 Message Date
Trey T 23f4d70ac1 fix: single keyboard Done toolbar on Complete Task (closes gitea#5)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The actualCost TextField and the notes TextEditor each had their own
`.keyboardDismissToolbar()` modifier, which installs a separate
`ToolbarItemGroup(placement: .keyboard)`. SwiftUI accumulates these
on the responder chain, so focusing any field rendered two "Done"
buttons stacked above the keyboard (issue screenshot in gitea#5).

Move the modifier up to the Form root so exactly one keyboard
toolbar is registered for the entire screen, matching the pattern
already used by `TaskFormView`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:58:19 -05:00
Trey t fdcf82757d fix(uploads): switch from S3 multipart POST to presigned PUT
Android UI Tests / ui-tests (push) Has been cancelled
Backblaze B2's S3-compatible endpoint does not implement the S3 POST
Object operation — every POST returns HTTP 501 regardless of URL form
(path-style or virtual-hosted-style). The previous multipart-POST flow
has been failing for every task-completion image upload.

Server-side companion change (honeyDueAPI master @7cc5448) replaces
PresignedPostPolicy with PresignHeader/PUT and renames the response
field from "fields" to "headers". This commit aligns both clients.

PresignUploadResponse model: field renamed `fields` → `headers`,
added `method` (default "PUT"). Both new fields have defaults so a
build talking to a stale server still decodes — albeit with empty
headers, which would then 403 at signature time. The server is
already on the new shape in prod.

iOS PresignedUploader.swift: dropped the ~70-line multipart body
builder and S3 form-field ordering logic. Replaced with a single PUT
request that applies server-supplied headers verbatim (skipping
Content-Length, which URLSession sets automatically and refuses to
override).

Android UploadApi.kt: same shape change. `postToStorage` →
`putToStorage`. Single Ktor `client.put()` with headers passthrough.
`uploadOne`'s `fileName` parameter kept for source compatibility but
marked @Suppress("UNUSED_PARAMETER") since PUT doesn't need it.

Verified end-to-end against api.myhoneydue.com:
  presign → PUT 12 bytes → HTTP 200 in 0.6s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:48:54 -05:00
Trey t 3890dd6f52 chore(network): point ApiConfig at PROD by default
Was on Environment.LOCAL — useful for local-against-127.0.0.1 dev but
means a release build off main hits a server the device can't reach.
Switch to Environment.PROD so the app talks to api.myhoneydue.com.
LOCAL/DEV are still one-line toggles in ApiConfig.kt for development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:48:54 -05:00
Trey T d5041492a9 test: add forceFreshLoginPerTest opt-in flag to AuthenticatedUITestCase
Android UI Tests / ui-tests (push) Has been cancelled
Default is `false` (current session-reuse behaviour) so tests reuse the
existing logged-in session — fast, and resilient to suites where the
current screen lacks a logout affordance (`UITestHelpers.ensureLoggedOut`
times out → tests fail before their bodies run).

Override to `true` in suites that observe transient `Invalid token` 401s
on POST/PATCH while reads continue to work. Recipe added after a 2026-05
incident where the API container was rebuilt mid-suite and in-memory
JWT tokens went stale; the diagnostic value is having an explicit lever
to reach for next time, not flipping the default.

Net effect on a clean simulator + stable API: 244/253 → 244/253 (no
behaviour change in the default path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:14:37 -05:00
admin ec5d93efab Merge pull request 'feat: bundle ID migration + gitea#2 task-cache fix (recovered from fix/task-cache-unification)' (#4) from feat/bundle-id-and-task-cache into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #4
2026-05-01 20:48:28 -05:00
6 changed files with 124 additions and 125 deletions
@@ -34,15 +34,20 @@ data class PresignUploadRequest(
/** /**
* Presigned upload session — response from POST /api/uploads/presign. * Presigned upload session — response from POST /api/uploads/presign.
* *
* The client uses [uploadUrl] + [fields] to perform a multipart/form-data * The client makes one PUT request to [uploadUrl] with the raw object
* POST directly to B2, then passes [id] back in the upload_ids[] field of * bytes as the body and [headers] as the request headers. On success,
* the next /api/task-completions/ or /api/documents/ create call. * 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 @Serializable
data class PresignUploadResponse( data class PresignUploadResponse(
val id: Int, val id: Int,
@SerialName("upload_url") val uploadUrl: String, @SerialName("upload_url") val uploadUrl: String,
val fields: Map<String, String>, val method: String = "PUT",
val headers: Map<String, String> = emptyMap(),
val key: String, val key: String,
@SerialName("expires_at") val expiresAt: String @SerialName("expires_at") val expiresAt: String
) )
@@ -10,7 +10,7 @@ package com.tt.honeyDue.network
*/ */
object ApiConfig { object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️ // ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
val CURRENT_ENV = Environment.LOCAL val CURRENT_ENV = Environment.PROD
enum class Environment { enum class Environment {
LOCAL, LOCAL,
@@ -5,7 +5,6 @@ import com.tt.honeyDue.models.PresignUploadResponse
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.* import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.utils.io.core.* import io.ktor.utils.io.core.*
@@ -14,17 +13,16 @@ import io.ktor.utils.io.core.*
* Three-step direct-to-B2 upload helper. * Three-step direct-to-B2 upload helper.
* *
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a * 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 * signed PUT URL plus the headers the client must send.
* direct upload. * Step 2: [putToStorage] — single PUT straight to B2. Bytes never traverse
* Step 2: [postToStorage] — multipart/form-data POST straight to B2. * our API server.
* Bytes never traverse our API server.
* Step 3: caller invokes the relevant entity-creation endpoint * Step 3: caller invokes the relevant entity-creation endpoint
* (POST /api/task-completions/, POST /api/documents/) with the * (POST /api/task-completions/, POST /api/documents/) with the
* returned upload_id in the `upload_ids` field. * returned upload_id in the `upload_ids` field.
* *
* iOS uses its own native equivalent (PresignedUploader.swift) for memory * iOS uses its own native equivalent (PresignedUploader.swift). Both paths
* reasons — Swift can stream a multipart body without buffering. Android * use PUT because B2's S3-compatible endpoint does not implement the S3
* uses this Kotlin path which works fine for ≤10 MB images. * POST Object form upload (returns HTTP 501 for any POST).
*/ */
class UploadApi(private val client: HttpClient = ApiClient.httpClient) { class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl() 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 presign signature binds the headers exactly, so we send them
* the file part, and `key` + `Content-Type` must match the policy * verbatim. Content-Length is filled in automatically by Ktor from
* exactly. Ktor's MultiPartFormDataContent preserves insertion order * the body size, but we still pass through Content-Type which Ktor
* for the appended parts. * would otherwise default to application/octet-stream.
*/ */
suspend fun postToStorage( suspend fun putToStorage(
uploadUrl: String, uploadUrl: String,
fields: Map<String, String>, headers: Map<String, String>,
data: ByteArray, data: ByteArray,
contentType: String, contentType: String,
fileName: String,
): ApiResult<Unit> { ): ApiResult<Unit> {
return try { return try {
val parts = formData { val response = client.put(uploadUrl) {
// Stable order: signed fields first, then file. We rely on // Apply server-supplied headers verbatim. Skip Content-Length
// Ktor preserving the order in which append() is called. // Ktor sets it automatically from the body and will refuse
fields.forEach { (k, v) -> append(k, v) } // a manual override on most engines.
append( headers.forEach { (k, v) ->
key = "file", if (!k.equals("Content-Length", ignoreCase = true)) {
value = data, header(k, v)
headers = Headers.build { }
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") }
append(HttpHeaders.ContentType, contentType) // 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()) { if (response.status.isSuccess()) {
ApiResult.Success(Unit) ApiResult.Success(Unit)
} else { } else {
@@ -124,7 +120,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
category: String, category: String,
contentType: String, contentType: String,
data: ByteArray, data: ByteArray,
fileName: String, @Suppress("UNUSED_PARAMETER") fileName: String,
): ApiResult<Int> { ): ApiResult<Int> {
val presignResult = presign(token, category, contentType, data.size.toLong()) val presignResult = presign(token, category, contentType, data.size.toLong())
val presigned = (presignResult as? ApiResult.Success)?.data val presigned = (presignResult as? ApiResult.Success)?.data
@@ -133,16 +129,15 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
(presignResult as? ApiResult.Error)?.code, (presignResult as? ApiResult.Error)?.code,
) )
val postResult = postToStorage( val putResult = putToStorage(
uploadUrl = presigned.uploadUrl, uploadUrl = presigned.uploadUrl,
fields = presigned.fields, headers = presigned.headers,
data = data, data = data,
contentType = contentType, contentType = contentType,
fileName = fileName,
) )
return when (postResult) { return when (putResult) {
is ApiResult.Success -> ApiResult.Success(presigned.id) is ApiResult.Success -> ApiResult.Success(presigned.id)
is ApiResult.Error -> postResult is ApiResult.Error -> putResult
else -> ApiResult.Error("Upload failed in unknown state") 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 { override func setUpWithError() throws {
guard TestAccountAPIClient.isBackendReachable() else { guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)") throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)")
@@ -41,27 +58,27 @@ class AuthenticatedUITestCase: BaseUITestCase {
try super.setUpWithError() try super.setUpWithError()
// If already logged in (tab bar visible), skip the login flow
let tabBar = app.tabBars.firstMatch let tabBar = app.tabBars.firstMatch
if tabBar.waitForExistence(timeout: defaultTimeout) { let alreadyLoggedIn = 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
}
// Not logged in do the full login flow // Force-fresh path: log out (if needed) and re-authenticate per
UITestHelpers.ensureLoggedOut(app: app) // test so every test starts with a freshly-issued JWT. Catches
loginToMainApp() // 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 { if needsAPISession {
guard let apiSession = TestAccountManager.loginSeededAccount( guard let apiSession = TestAccountManager.loginSeededAccount(
+39 -61
View File
@@ -4,15 +4,18 @@ import ComposeApp
/// Three-step direct-to-B2 image upload. /// Three-step direct-to-B2 image upload.
/// ///
/// Flow: /// Flow:
/// 1. POST /api/uploads/presign server returns a B2 POST policy + form /// 1. POST /api/uploads/presign server returns a signed PUT URL plus
/// fields scoped to a single object key with a content-length-range /// the headers (Content-Type, Content-Length) the client must send.
/// condition that B2 enforces at the protocol level. /// The signature binds those headers B2 rejects the upload if the
/// 2. Multipart POST the bytes directly to B2, no API server in the data /// bytes/headers don't match exactly.
/// path. B2 rejects the upload if the bytes don't match the policy. /// 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 /// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
/// /api/documents/ via `upload_ids[]`. The server HEADs the object, /// /api/documents/ via `upload_ids[]`. The server HEADs the object,
/// confirms the size, and creates the linked entity rows. /// 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 /// All errors map to `PresignedUploaderError` the Swift call site can
/// translate to user-facing copy without parsing nested HTTP details. /// translate to user-facing copy without parsing nested HTTP details.
enum PresignedUploaderError: Error, LocalizedError { enum PresignedUploaderError: Error, LocalizedError {
@@ -33,7 +36,7 @@ enum PresignedUploaderError: Error, LocalizedError {
default: return "Couldn't start upload (server returned \(status))." default: return "Couldn't start upload (server returned \(status))."
} }
case .uploadFailed(let status, _): case .uploadFailed(let status, _):
return "Upload failed (B2 returned \(status))." return "Upload failed (storage returned \(status))."
case .sessionError(let err): case .sessionError(let err):
return err.localizedDescription return err.localizedDescription
} }
@@ -95,13 +98,12 @@ final class PresignedUploader {
contentLength: Int64(data.count) contentLength: Int64(data.count)
) )
// Step 2: direct POST to B2 // Step 2: direct PUT to B2
try await postToStorage( try await putToStorage(
uploadURL: presigned.uploadUrl, uploadURL: presigned.uploadUrl,
fields: presigned.fields, headers: presigned.headers,
data: data, data: data,
contentType: contentType, contentType: contentType
fileName: fileName
) )
return Int32(presigned.id) return Int32(presigned.id)
@@ -146,7 +148,8 @@ final class PresignedUploader {
private struct PresignResponse: Decodable { private struct PresignResponse: Decodable {
let id: Int let id: Int
let upload_url: String let upload_url: String
let fields: [String: String] let method: String?
let headers: [String: String]
let key: String let key: String
let expires_at: 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, uploadURL: String,
fields: [String: String], headers: [String: String],
data: Data, data: Data,
contentType: String, contentType: String
fileName: String
) async throws { ) async throws {
guard let url = URL(string: uploadURL) else { guard let url = URL(string: uploadURL) else {
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url") 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) var req = URLRequest(url: url)
req.httpMethod = "POST" req.httpMethod = "PUT"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") req.httpBody = data
req.httpBody = body
// 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) let (respBody, response): (Data, URLResponse)
do { do {
+6 -2
View File
@@ -120,7 +120,6 @@ struct CompleteTaskView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding(.leading, 12) .padding(.leading, 12)
.keyboardDismissToolbar()
.accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField) .accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField)
} label: { } label: {
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle") Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
@@ -142,7 +141,6 @@ struct CompleteTaskView: View {
TextEditor(text: $notes) TextEditor(text: $notes)
.frame(minHeight: 100) .frame(minHeight: 100)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.keyboardDismissToolbar()
.accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField) .accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField)
} }
} footer: { } footer: {
@@ -289,6 +287,12 @@ struct CompleteTaskView: View {
.background(WarmGradientBackground()) .background(WarmGradientBackground())
.navigationTitle(L10n.Tasks.completeTask) .navigationTitle(L10n.Tasks.completeTask)
.navigationBarTitleDisplayMode(.inline) .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 { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button(L10n.Common.cancel) { Button(L10n.Common.cancel) {