9 Commits

Author SHA1 Message Date
Trey T 0b6f26da99 fix(qlpreview): hide share-arrow in expired state (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The down-chevron above the system Share button is a "tap here"
cue for the active flow. In the expired state there's nothing
worth sharing (the bundled code will be rejected on import) so
the arrow is misleading; hide it whenever we render the
"This invite has expired" message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:21:57 -05:00
Trey T 83c3428b05 fix(qlpreview): expired-state copy + dedicated row text (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
When the share link's expiry is in the past, the preview now
swaps the "How to join" steps for a dead-end message ("This
invite has expired. Ask <sender> to send a new link.") and
re-words the clock row to "Expired 1 hour ago" so users don't
see share-sheet directions for a link the server will reject.

Also adds an expired-state snapshot test alongside the existing
active-state one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:57:54 -05:00
Trey T f4c2780e34 fix(qlpreview): inline share icon instead of fixed position (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The previous copy "1. Tap the Share button (top right of this preview)"
named a position that's wrong on iOS file-preview chrome (the share
button is at the BOTTOM, not the top), and may move across iOS
versions / contexts (mail attachment vs Files vs AirDrop).

Switch the instruction to an attributed string that inlines the
universal iOS share glyph (SF Symbol `square.and.arrow.up`) next to
"Tap" — the recipient finds the right control by sight regardless of
where the chrome puts it. New `PreviewViewController.makeResidenceInstructions()`
builds the attributed string with the glyph attachment vertically
aligned to the body-text baseline.

`Issue7PreviewScreenshotTest` mirrors the new builder so the recorded
PNG attached to the gitea issue stays in sync with production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:46:59 -05:00
Trey T d26714f043 test(qlpreview): screenshot of the post-fix residence-invite preview (gitea#7)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Adds a one-shot SnapshotTesting case that renders the new
`PreviewViewController.updateUIForResidence` layout on the iPhone-13
simulator with deterministic data ("The Tartt's", expiry exactly 23h
in the future). The PNG it writes is what gets attached to issue #7
so reviewers can see the post-fix look without AirDropping a
`.honeydue` file to a device.

`MockPreviewViewController` mirrors the production UIKit layout
1:1 — same colors, fonts, constraints, image asset. (The QL extension
target itself can't be `@testable import`ed from HoneyDueTests
without project-file surgery; the mirror is a pragmatic faithful copy
so we get a real on-simulator render via SnapshotTesting.)

The included PNG is the recorded golden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:44:29 -05:00
Trey T 5aa31153e3 fix: share-residence import preview polish (closes gitea#7)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Issue #7 called out four problems with the QuickLook preview iOS
recipients see when they open a `.honeydue` invite (e.g. via AirDrop or
Save to Files). All four fixed here.

1. Filename: keep spaces and apostrophes
   `HoneyDueShareCodec.safeShareFileName` previously replaced every space
   with an underscore, so the system title bar rendered "The_Tartt's"
   instead of "The Tartt's". Now we strip only the characters that are
   actually unsafe on iOS / Android filesystems (`/`, `\`, `:`, `*`,
   `?`, `"`, `<`, `>`, `|`, non-whitespace control codepoints) and
   collapse internal whitespace to single spaces. Locked in with six
   new commonTest cases.

2. Icon: brand logo instead of generic house glyph
   `PreviewViewController.updateUIForResidence` was using
   `UIImage(systemName: "house.fill")` — recipients couldn't tell at a
   glance that this was a HoneyDue invite. The honeyDue app logo
   (Assets.xcassets/AppLogo) is now loaded from a new asset catalog in
   the QL preview bundle and rendered in original colors. SF Symbol
   fallback retained for any asset-load failure.

3. Expires-at: human-readable phrase, not a raw ISO timestamp
   The previous "Expires: 2026-05-12T17:11:02.067272789Z" line is now
   formatted via `RelativeDateTimeFormatter` for invites that lapse
   within a day ("in 5 hours") and a localized medium-date + short-time
   string ("on May 12, 2026 at 5:11 PM") otherwise. Already-expired
   links render "expired 2 hours ago". Falls back to the raw string if
   ISO parsing fails so nothing ever goes blank.

4. Instructions: numbered, explicit, action-clear
   The single-line "Tap the share button below, then select..." copy
   pointed at the wrong location (the share button is at the top of
   the QuickLook chrome, not "below") and assumed the recipient
   recognised the share affordance. Replaced with a three-step list.

Tests: new `HoneyDueShareCodecTest` (commonTest, 6 cases) covers the
filename contract end-to-end — passes on the JVM unit-test target.
No iOS unit test for the date formatter because the SDK helpers it
uses (`RelativeDateTimeFormatter`, `ISO8601DateFormatter`) are
deterministic enough to spot-check by hand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:07:13 -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
15 changed files with 807 additions and 130 deletions
@@ -59,12 +59,29 @@ object HoneyDueShareCodec {
/** /**
* Build a filesystem-safe package filename with `.honeydue` extension. * Build a filesystem-safe package filename with `.honeydue` extension.
*
* Strips only the characters that are actually unsafe on iOS / Android
* filesystems (`/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, control
* chars). Spaces and apostrophes are kept intact so the recipient sees
* the original residence / contractor name in the iOS QuickLook title
* bar — gitea#7 called out the previous behaviour rendering
* "The_Tartt's" instead of "The Tartt's". Internal whitespace is
* collapsed to single spaces and trimmed; falls back to "honeyDue" if
* the input is blank after sanitising.
*/ */
fun safeShareFileName(displayName: String): String { fun safeShareFileName(displayName: String): String {
val safeName = displayName val safeName = displayName
.replace(" ", "_") // Keep whitespace through the filter so adjacent space+tab
.replace("/", "-") // sequences survive to the regex-collapse step below. Drop
// only non-whitespace control chars (NUL etc.) plus the
// explicit filesystem-unsafe set.
.filter { it !in UNSAFE_FILENAME_CHARS && (it.isWhitespace() || !it.isISOControl()) }
.replace(Regex("\\s+"), " ")
.trim()
.take(50) .take(50)
.ifBlank { "honeyDue" }
return "$safeName.honeydue" return "$safeName.honeydue"
} }
private val UNSAFE_FILENAME_CHARS = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
} }
@@ -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")
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppLogo@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -263,13 +263,40 @@ class PreviewViewController: UIViewController, QLPreviewingController {
} }
private func updateUIForResidence(with residence: ResidencePreviewData) { private func updateUIForResidence(with residence: ResidencePreviewData) {
// Update icon // Brand icon. Prefer the bundled honeyDue logo so the preview
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light) // reads as a HoneyDue invite at a glance; fall back to a tinted
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config) // SF Symbol for accessibility / asset-load failures.
if let logo = UIImage(named: "AppLogo") {
iconImageView.image = logo.withRenderingMode(.alwaysOriginal)
iconImageView.contentMode = .scaleAspectFit
iconImageView.layer.cornerRadius = 16
iconImageView.layer.masksToBounds = true
} else {
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
}
titleLabel.text = residence.residenceName titleLabel.text = residence.residenceName
subtitleLabel.text = "honeyDue Residence Invite" subtitleLabel.text = "honeyDue Residence Invite"
instructionLabel.text = "Tap the share button below, then select \"honeyDue\" to join this residence."
// Branch the copy on whether the share link has already lapsed.
// Active invites get the standard "How to join" numbered steps;
// expired invites get a clear dead-end message asking the
// recipient to ping the sender for a new link no point
// showing share-sheet directions for a link the server will
// reject.
let expiredAgo = Self.expiredRelativePhraseOrNil(residence.expiresAt)
if let expiredAgo {
instructionLabel.attributedText = Self.makeExpiredInstructions(sharedBy: residence.sharedBy)
// The down-chevron points at the Share button as a visual
// cue to tap it; in the expired state there's nothing
// useful to share (the server will reject the bundled
// code) so the arrow becomes misleading. Hide it.
arrowImageView.isHidden = true
} else {
instructionLabel.attributedText = Self.makeResidenceInstructions()
arrowImageView.isHidden = false
}
// Clear existing details // Clear existing details
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
@@ -280,9 +307,183 @@ class PreviewViewController: UIViewController, QLPreviewingController {
} }
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty { if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)") if let expiredAgo {
// "Expired 1 hour ago" capitalised past-tense; no
// "Expires " prefix because the share link no longer
// expires, it has already done so (gitea#7 review).
addDetailRow(icon: "clock", text: "Expired \(expiredAgo)")
} else {
let formatted = Self.formatActiveExpiry(expiresAt)
addDetailRow(icon: "clock", text: "Expires \(formatted)")
}
} }
} }
// MARK: - Formatting helpers
/// Render an *active* (not-yet-expired) share-link expiry as a
/// human-readable phrase. Within a day uses
/// `RelativeDateTimeFormatter` ("in 23 hours" / "in 12 minutes");
/// further out switches to absolute date + time so users planning
/// ahead see exactly when the invite lapses. Falls back to the raw
/// ISO string if parsing fails so the row never goes blank.
///
/// Callers must check [expiredRelativePhraseOrNil] first this
/// function assumes a future expiry and produces wording that only
/// makes sense in that case.
static func formatActiveExpiry(_ isoString: String) -> String {
guard let date = parseIsoDate(isoString) else { return isoString }
let now = Date()
let elapsed = date.timeIntervalSince(now)
if elapsed < 24 * 60 * 60 {
return relativeFormatter.localizedString(for: date, relativeTo: now)
}
return "on \(absoluteFormatter.string(from: date))"
}
/// If the share link has already lapsed, return the relative
/// "X ago" phrase. `nil` means active (or unparseable) callers
/// should fall back to [formatActiveExpiry] for those cases. The
/// split lets `updateUIForResidence` branch the entire UI block
/// (row text + instruction card) on the same signal (gitea#7
/// review: an expired link should send the recipient back to the
/// sender for a new invite, not show share-sheet directions for a
/// link the server will reject).
static func expiredRelativePhraseOrNil(_ isoString: String?) -> String? {
guard let isoString, let date = parseIsoDate(isoString) else { return nil }
let now = Date()
if date.timeIntervalSince(now) > 0 { return nil }
return relativeFormatter.localizedString(for: date, relativeTo: now)
}
private static func parseIsoDate(_ raw: String) -> Date? {
if let d = isoFormatterWithFraction.date(from: raw) { return d }
if let d = isoFormatterNoFraction.date(from: raw) { return d }
return nil
}
private static let isoFormatterWithFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .full
return f
}()
private static let absoluteFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
/// Builds the "How to join" instruction copy as an attributed
/// string with the iOS share-icon glyph (square + up-arrow) inlined
/// next to "Tap [icon]". The glyph is the universal share symbol
/// across iOS, so the recipient finds the right control whether
/// it's at the top, bottom, or behind a More menu instead of us
/// claiming a fixed position the chrome can move (gitea#7 review
/// feedback).
private static func makeResidenceInstructions() -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
func appendText(_ s: String) {
result.append(NSAttributedString(
string: s,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
}
appendText("How to join:\n1. Tap ")
let shareImage = UIImage(
systemName: "square.and.arrow.up",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
if let shareImage {
let attachment = NSTextAttachment()
attachment.image = shareImage
// Align the glyph baseline with the surrounding text by
// nudging the bounds down a few points; the SF Symbol's
// natural bounds sit a hair above the cap height.
attachment.bounds = CGRect(
x: 0,
y: -3,
width: shareImage.size.width,
height: shareImage.size.height
)
result.append(NSAttributedString(attachment: attachment))
}
appendText("\n2. Choose \"honeyDue\" from the share sheet")
appendText("\n3. Sign in if prompted — the app finishes the rest")
return result
}
/// Expired-state copy for the instruction card. Tells the recipient
/// the share link is no longer valid and to ping the sender (by
/// email if we know it) for a new one replaces the active "How to
/// join" steps since the server will reject the bundled code
/// anyway.
private static func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
// Slightly warmer tint than the active instruction copy the
// app's `appError` red would feel alarmist for "just ask again",
// and the secondary-label gray reads as muted/disabled which is
// accurate to the link's actual state.
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor.secondaryLabel
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
let titleTint = UIColor.label
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
result.append(NSAttributedString(
string: "This invite has expired.\n",
attributes: [
.font: titleFont,
.foregroundColor: titleTint,
.paragraphStyle: paragraph,
]
))
let body = if let s = sharedBy, !s.isEmpty {
"Ask \(s) to send a new link."
} else {
"Ask the sender to share a new link."
}
result.append(NSAttributedString(
string: body,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
return result
}
} }
// MARK: - Type Discriminator // MARK: - Type Discriminator
@@ -0,0 +1,437 @@
//
// Issue7PreviewScreenshotTest.swift
// HoneyDueTests
//
// Records a single PNG screenshot of the post-fix QL-preview layout
// used by `HoneyDueQLPreview/PreviewViewController.swift` so it can be
// attached to gitea issue #7 for the reviewer to see the new look
// without having to AirDrop a `.honeydue` file to a device.
//
// How it works:
// * Faithfully recreates the UIKit layout `PreviewViewController.updateUIForResidence`
// builds in production same colors, same fonts, same constraints,
// same image asset (copied into `HoneyDueTests/Resources/AppLogo.png`
// so it is reachable from this target's bundle).
// * Runs the same `formatExpiresAt` style (ISO parse relative phrase
// when within a day, absolute medium-date + short-time otherwise),
// using a fixed reference Date so the rendering is deterministic
// across runs / time zones.
// * `SnapshotTesting.assertSnapshot(of: viewController, as: .image)`
// writes the PNG to
// `iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/`.
//
// The first run (no committed golden) records the PNG and the test
// reports "failed - No reference was found on disk. Automatically
// recorded snapshot:" that's the file we attach to the issue.
//
// Note on faithfulness: this snapshot is a programmatic reproduction
// of `PreviewViewController.updateUIForResidence`, not the QL
// extension instance itself, because the QL extension's bundle is a
// separate Xcode target from `HoneyDueTests` and can't be `@testable
// import`ed without project-file surgery. The reproduction uses the
// same UIKit primitives, colors, fonts, and asset, so the rendered
// output matches what users see when iOS opens a `.honeydue` invite.
//
@preconcurrency import SnapshotTesting
import UIKit
import XCTest
@MainActor
final class Issue7PreviewScreenshotTest: XCTestCase {
/// Force record mode for this test only we want the PNG written
/// regardless of whether a golden exists.
override func invokeTest() {
withSnapshotTesting(record: .all) {
super.invokeTest()
}
}
func test_residence_invite_preview_after_issue7_fix() {
let vc = MockPreviewViewController(
residence: ResidencePreview.fixtureForIssue7,
state: .active
)
vc.overrideUserInterfaceStyle = .dark
assertSnapshot(
of: vc,
as: .image(
on: .iPhone13,
precision: 1.0,
perceptualPrecision: 1.0,
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: 2.0),
])
),
named: "issue7_residence_invite_preview_dark"
)
}
func test_residence_invite_preview_expired_state() {
// Same residence + sender, but expiry already 1 hour in the
// past. Verifies the expired branch: the instruction card
// swaps to "ask the sender for a new link" and the detail row
// reads "Expired 1 hour ago" instead of the future-tense
// "Expires in " phrasing.
let vc = MockPreviewViewController(
residence: ResidencePreview.fixtureForIssue7,
state: .expired(elapsedSecondsSinceExpiry: 60 * 60)
)
vc.overrideUserInterfaceStyle = .dark
assertSnapshot(
of: vc,
as: .image(
on: .iPhone13,
precision: 1.0,
perceptualPrecision: 1.0,
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: 2.0),
])
),
named: "issue7_residence_invite_preview_expired_dark"
)
}
}
// MARK: - Sample residence (matches the gitea#7 screenshot setup)
private struct ResidencePreview {
let residenceName: String
let sharedBy: String?
let expiresAt: String?
/// Mirrors the data shown in the original gitea#7 screenshot the
/// post-fix version of the same payload.
static let fixtureForIssue7 = ResidencePreview(
residenceName: "The Tartt's",
sharedBy: "honey@hollie37.com",
expiresAt: "2026-05-12T17:11:02.067272789Z"
)
}
// MARK: - Mock view controller (UIKit copy of `updateUIForResidence`)
/// Renderer state for the screenshot fixture. Active = link still
/// valid; expired = link lapsed `elapsedSecondsSinceExpiry` seconds
/// ago. Both render with deterministic data so the recorded PNG is
/// stable across runs.
private enum PreviewRenderState {
case active
case expired(elapsedSecondsSinceExpiry: TimeInterval)
}
@MainActor
private final class MockPreviewViewController: UIViewController {
private let residence: ResidencePreview
private let state: PreviewRenderState
private let containerView = UIView()
private let iconImageView = UIImageView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let dividerView = UIView()
private let detailsStackView = UIStackView()
private let instructionCard = UIView()
private let instructionLabel = UILabel()
private let arrowImageView = UIImageView()
init(residence: ResidencePreview, state: PreviewRenderState) {
self.residence = residence
self.state = state
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("not used") }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
applyResidence()
}
private func setupUI() {
view.backgroundColor = .systemBackground
containerView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.contentMode = .scaleAspectFit
iconImageView.tintColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
titleLabel.textColor = .label
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 2
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
subtitleLabel.font = .systemFont(ofSize: 15, weight: .medium)
subtitleLabel.textColor = .secondaryLabel
subtitleLabel.textAlignment = .center
dividerView.translatesAutoresizingMaskIntoConstraints = false
dividerView.backgroundColor = .separator
detailsStackView.translatesAutoresizingMaskIntoConstraints = false
detailsStackView.axis = .vertical
detailsStackView.spacing = 12
detailsStackView.alignment = .leading
instructionCard.translatesAutoresizingMaskIntoConstraints = false
instructionCard.backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 0.1)
instructionCard.layer.cornerRadius = 12
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
instructionLabel.font = .systemFont(ofSize: 15, weight: .medium)
instructionLabel.textColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
instructionLabel.textAlignment = .left
instructionLabel.numberOfLines = 0
arrowImageView.translatesAutoresizingMaskIntoConstraints = false
arrowImageView.contentMode = .scaleAspectFit
arrowImageView.tintColor = .secondaryLabel
let arrowConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
arrowImageView.image = UIImage(systemName: "arrow.down", withConfiguration: arrowConfig)
view.addSubview(containerView)
containerView.addSubview(iconImageView)
containerView.addSubview(titleLabel)
containerView.addSubview(subtitleLabel)
containerView.addSubview(dividerView)
containerView.addSubview(detailsStackView)
containerView.addSubview(instructionCard)
instructionCard.addSubview(instructionLabel)
containerView.addSubview(arrowImageView)
NSLayoutConstraint.activate([
containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
containerView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
containerView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 340),
iconImageView.topAnchor.constraint(equalTo: containerView.topAnchor),
iconImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
iconImageView.widthAnchor.constraint(equalToConstant: 80),
iconImageView.heightAnchor.constraint(equalToConstant: 80),
titleLabel.topAnchor.constraint(equalTo: iconImageView.bottomAnchor, constant: 16),
titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
dividerView.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 20),
dividerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
dividerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
dividerView.heightAnchor.constraint(equalToConstant: 1),
detailsStackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20),
detailsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
detailsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
instructionCard.topAnchor.constraint(equalTo: detailsStackView.bottomAnchor, constant: 24),
instructionCard.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
instructionCard.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
instructionLabel.topAnchor.constraint(equalTo: instructionCard.topAnchor, constant: 16),
instructionLabel.leadingAnchor.constraint(equalTo: instructionCard.leadingAnchor, constant: 16),
instructionLabel.trailingAnchor.constraint(equalTo: instructionCard.trailingAnchor, constant: -16),
instructionLabel.bottomAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: -16),
arrowImageView.topAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: 16),
arrowImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
arrowImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
}
private func applyResidence() {
// Mirror the post-fix branding choice: bundled honeyDue logo
// rendered in its actual colors. The image ships with the test
// target at `Resources/AppLogo.png`.
if let path = Bundle(for: Self.self).path(forResource: "AppLogo", ofType: "png"),
let logo = UIImage(contentsOfFile: path) {
iconImageView.image = logo
iconImageView.layer.cornerRadius = 16
iconImageView.layer.masksToBounds = true
} else {
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
}
titleLabel.text = residence.residenceName
subtitleLabel.text = "honeyDue Residence Invite"
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
}
switch state {
case .active:
instructionLabel.attributedText = makeResidenceInstructions()
arrowImageView.isHidden = false
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires \(formatActiveExpiry(expiresAt))")
}
case .expired(let elapsed):
instructionLabel.attributedText = makeExpiredInstructions(sharedBy: residence.sharedBy)
// Arrow points at the Share button no point telling the
// user to tap it for a dead link. Matches PreviewViewController.
arrowImageView.isHidden = true
addDetailRow(icon: "clock", text: "Expired \(relativePhrase(secondsAgo: elapsed))")
}
}
private func relativePhrase(secondsAgo: TimeInterval) -> String {
// Deterministic relative phrase we set "now" to be exactly
// `secondsAgo` after the (fake) expiry, so the formatter says
// "1 hour ago" instead of whatever the real clock would give.
let fakeNow = Date()
let pastExpiry = fakeNow.addingTimeInterval(-secondsAgo)
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .full
return relative.localizedString(for: pastExpiry, relativeTo: fakeNow)
}
/// Expired-state copy mirroring `PreviewViewController.makeExpiredInstructions`.
private func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
result.append(NSAttributedString(
string: "This invite has expired.\n",
attributes: [
.font: titleFont,
.foregroundColor: UIColor.label,
.paragraphStyle: paragraph,
]
))
let body = if let s = sharedBy, !s.isEmpty {
"Ask \(s) to send a new link."
} else {
"Ask the sender to share a new link."
}
result.append(NSAttributedString(
string: body,
attributes: [
.font: bodyFont,
.foregroundColor: UIColor.secondaryLabel,
.paragraphStyle: paragraph,
]
))
return result
}
private func addDetailRow(icon: String, text: String) {
let row = UIStackView()
row.axis = .horizontal
row.spacing = 12
row.alignment = .center
let iv = UIImageView()
iv.translatesAutoresizingMaskIntoConstraints = false
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
iv.image = UIImage(systemName: icon, withConfiguration: config)
iv.tintColor = .secondaryLabel
iv.widthAnchor.constraint(equalToConstant: 24).isActive = true
iv.heightAnchor.constraint(equalToConstant: 24).isActive = true
let label = UILabel()
label.font = .systemFont(ofSize: 15)
label.textColor = .label
label.text = text
label.numberOfLines = 1
row.addArrangedSubview(iv)
row.addArrangedSubview(label)
detailsStackView.addArrangedSubview(row)
}
/// Mirrors `PreviewViewController.makeResidenceInstructions()` see
/// the rationale comment there. Inlined here because the QL
/// extension target can't be `@testable import`ed without
/// project-file surgery.
private func makeResidenceInstructions() -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
func appendText(_ s: String) {
result.append(NSAttributedString(
string: s,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
}
appendText("How to join:\n1. Tap ")
let shareImage = UIImage(
systemName: "square.and.arrow.up",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
if let shareImage {
let attachment = NSTextAttachment()
attachment.image = shareImage
attachment.bounds = CGRect(
x: 0,
y: -3,
width: shareImage.size.width,
height: shareImage.size.height
)
result.append(NSAttributedString(attachment: attachment))
}
appendText("\n2. Choose \"honeyDue\" from the share sheet")
appendText("\n3. Sign in if prompted — the app finishes the rest")
return result
}
// Mirrors PreviewViewController.formatActiveExpiry with a fixed
// "now" so the rendering is identical regardless of when the test
// runs. The expired branch uses [relativePhrase(secondsAgo:)]
// instead see the active/expired switch in `applyResidence`.
private func formatActiveExpiry(_ raw: String) -> String {
let isoWithFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
let isoNoFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
guard let date = isoWithFraction.date(from: raw)
?? isoNoFraction.date(from: raw) else {
return raw
}
// Deterministic "now": 23 hours before the fixture's expiry, so
// the relative formatter always produces "in 23 hours".
let fakeNow = date.addingTimeInterval(-23 * 60 * 60)
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .full
return relative.localizedString(for: date, relativeTo: fakeNow)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -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 {