db65db6232
Android UI Tests / ui-tests (push) Has been cancelled
Localize all user-facing strings across iOS (SwiftUI), shared Kotlin, and Android Compose into en/es/fr/de/pt/it/ja/ko/nl/zh: - iOS String Catalogs: main + widget Localizable.xcstrings, InfoPlist.xcstrings (permissions), plural variations, ~200 new keys translated - Shared Kotlin ClientStrings table + Android composeResources/values-* (884 keys ×10), routed Api/ViewModel/util error & UI strings through localization - Backend-localized lookups/suggestions consumed via display names - Widget extension catalog; theme names, home-profile fallbacks, validation, network errors, accessibility labels all localized Add re-runnable verification gates: - scripts/i18n_audit.py — enumerate every literal, partition to GAP=0 - scripts/i18n_coverage.py — all 10 locales translated, format-specifier parity Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
254 lines
9.9 KiB
Swift
254 lines
9.9 KiB
Swift
import Foundation
|
|
import ComposeApp
|
|
|
|
/// Three-step direct-to-B2 image upload.
|
|
///
|
|
/// Flow:
|
|
/// 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 {
|
|
case notAuthenticated
|
|
case presignFailed(status: Int, body: String)
|
|
case uploadFailed(status: Int, body: String)
|
|
case sessionError(Error)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notAuthenticated:
|
|
return String(localized: "You're not signed in.")
|
|
case .presignFailed(let status, _):
|
|
switch status {
|
|
case 413: return String(localized: "That photo is too large after resizing. Try a different one.")
|
|
case 422: return String(localized: "That image format isn't supported.")
|
|
case 429: return String(localized: "You're uploading too many photos. Try again in a few minutes.")
|
|
default: return String(format: String(localized: "Couldn't start upload (server returned %lld)."), status)
|
|
}
|
|
case .uploadFailed(let status, _):
|
|
return String(format: String(localized: "Upload failed (storage returned %lld)."), status)
|
|
case .sessionError(let err):
|
|
return err.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Category passed to the presign endpoint. Matches the Go server's
|
|
/// `UploadCategory` constants in `internal/models/pending_upload.go`.
|
|
enum UploadCategory: String {
|
|
case completion = "completion"
|
|
case documentImage = "document_image"
|
|
case documentFile = "document_file"
|
|
}
|
|
|
|
/// Presigned-URL upload helper. Stateless — instantiate freely.
|
|
///
|
|
/// Concurrency: each `upload(...)` call runs to completion sequentially.
|
|
/// For multiple images the caller can run several uploads in parallel via
|
|
/// `withTaskGroup`; the server's per-user concurrency cap (10 in-flight
|
|
/// presigns) is enforced server-side.
|
|
final class PresignedUploader {
|
|
|
|
/// API base URL — read from KMP's ApiConfig so iOS and Android stay
|
|
/// in sync (LOCAL vs DEV vs PROD without divergent constants).
|
|
private let apiBaseURL: String
|
|
|
|
/// Bearer token. Read once at init; if the user re-auths mid-session,
|
|
/// the caller should construct a fresh PresignedUploader.
|
|
private let authToken: String
|
|
|
|
private let session: URLSession
|
|
|
|
init?(session: URLSession = .shared) {
|
|
// ApiConfig.shared.getBaseUrl() resolves Environment (LOCAL/DEV/PROD).
|
|
// DataManager.shared.authToken is a StateFlow<String?> — read the
|
|
// current value via .value (SKIE-exposed property).
|
|
let baseUrl = ApiConfig.shared.getBaseUrl()
|
|
guard let token = DataManager.shared.authToken.value as String? else {
|
|
return nil
|
|
}
|
|
self.apiBaseURL = baseUrl
|
|
self.authToken = token
|
|
self.session = session
|
|
}
|
|
|
|
/// Upload `data` to B2 in the named category. Returns the
|
|
/// pending_uploads.id the caller passes via `upload_ids[]` to attach
|
|
/// the object to a real entity.
|
|
func upload(
|
|
data: Data,
|
|
category: UploadCategory,
|
|
contentType: String = "image/jpeg",
|
|
fileName: String = "image.jpg"
|
|
) async throws -> Int32 {
|
|
// Step 1: presign
|
|
let presigned = try await requestPresign(
|
|
category: category,
|
|
contentType: contentType,
|
|
contentLength: Int64(data.count)
|
|
)
|
|
|
|
// Step 2: direct PUT to B2
|
|
try await putToStorage(
|
|
uploadURL: presigned.uploadUrl,
|
|
headers: presigned.headers,
|
|
data: data,
|
|
contentType: contentType
|
|
)
|
|
|
|
return Int32(presigned.id)
|
|
}
|
|
|
|
/// Upload several images in parallel, returning their upload_ids in
|
|
/// input order. Stops at the first failure and surfaces it.
|
|
func uploadAll(
|
|
items: [(Data, String)],
|
|
category: UploadCategory,
|
|
contentType: String = "image/jpeg"
|
|
) async throws -> [Int32] {
|
|
try await withThrowingTaskGroup(of: (Int, Int32).self) { group in
|
|
for (idx, item) in items.enumerated() {
|
|
let (data, name) = item
|
|
group.addTask { [self] in
|
|
let id = try await upload(
|
|
data: data,
|
|
category: category,
|
|
contentType: contentType,
|
|
fileName: name
|
|
)
|
|
return (idx, id)
|
|
}
|
|
}
|
|
var pairs: [(Int, Int32)] = []
|
|
for try await pair in group {
|
|
pairs.append(pair)
|
|
}
|
|
return pairs.sorted { $0.0 < $1.0 }.map { $0.1 }
|
|
}
|
|
}
|
|
|
|
// MARK: - Step 1: presign
|
|
|
|
private struct PresignBody: Encodable {
|
|
let category: String
|
|
let content_type: String
|
|
let content_length: Int64
|
|
}
|
|
|
|
private struct PresignResponse: Decodable {
|
|
let id: Int
|
|
let upload_url: String
|
|
let method: String?
|
|
let headers: [String: String]
|
|
let key: String
|
|
let expires_at: String
|
|
|
|
// Map snake_case to nicer Swift names at the call site.
|
|
var uploadUrl: String { upload_url }
|
|
}
|
|
|
|
private func requestPresign(
|
|
category: UploadCategory,
|
|
contentType: String,
|
|
contentLength: Int64
|
|
) async throws -> PresignResponse {
|
|
guard var url = URL(string: apiBaseURL) else {
|
|
throw PresignedUploaderError.presignFailed(status: 0, body: "invalid base url") // i18n-ignore: internal diagnostic body, not surfaced (errorDescription ignores it)
|
|
}
|
|
url.appendPathComponent("uploads/presign/")
|
|
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = "POST"
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
// honeyDue API auth: Kratos session token on X-Session-Token.
|
|
req.setValue(authToken, forHTTPHeaderField: "X-Session-Token")
|
|
req.httpBody = try JSONEncoder().encode(PresignBody(
|
|
category: category.rawValue,
|
|
content_type: contentType,
|
|
content_length: contentLength
|
|
))
|
|
|
|
let (body, response): (Data, URLResponse)
|
|
do {
|
|
(body, response) = try await session.data(for: req)
|
|
} catch {
|
|
throw PresignedUploaderError.sessionError(error)
|
|
}
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw PresignedUploaderError.presignFailed(status: 0, body: "no response") // i18n-ignore: internal diagnostic body, not surfaced
|
|
}
|
|
guard (200..<300).contains(http.statusCode) else {
|
|
throw PresignedUploaderError.presignFailed(
|
|
status: http.statusCode,
|
|
body: String(data: body, encoding: .utf8) ?? ""
|
|
)
|
|
}
|
|
do {
|
|
return try JSONDecoder().decode(PresignResponse.self, from: body)
|
|
} catch {
|
|
throw PresignedUploaderError.presignFailed(status: http.statusCode, body: "decode failed: \(error)") // i18n-ignore: internal diagnostic body, not surfaced
|
|
}
|
|
}
|
|
|
|
// 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 putToStorage(
|
|
uploadURL: String,
|
|
headers: [String: String],
|
|
data: Data,
|
|
contentType: String
|
|
) async throws {
|
|
guard let url = URL(string: uploadURL) else {
|
|
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url") // i18n-ignore: internal diagnostic body, not surfaced
|
|
}
|
|
|
|
var req = URLRequest(url: url)
|
|
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 {
|
|
(respBody, response) = try await session.data(for: req)
|
|
} catch {
|
|
throw PresignedUploaderError.sessionError(error)
|
|
}
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw PresignedUploaderError.uploadFailed(status: 0, body: "no response") // i18n-ignore: internal diagnostic body, not surfaced
|
|
}
|
|
guard (200..<300).contains(http.statusCode) else {
|
|
throw PresignedUploaderError.uploadFailed(
|
|
status: http.statusCode,
|
|
body: String(data: respBody, encoding: .utf8) ?? ""
|
|
)
|
|
}
|
|
}
|
|
}
|