mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
Attachments (Android B-M3f semantics ported verbatim): - HapiClient/Attachments/AttachmentPolicy — pure plan matrix (>4 MB recompressible image -> 2048 px JPEG q85 with .jpg rename, 50 MB hard reject, 192 MB image read cap, 512 px q80 previewUrl data-URL thumbs, data-URL parse/round-trip). - HapiClient/Attachments/ComposerAttachments — upload-on-pick tray over an AttachmentUploading seam (APIClient conforms): uploading/ready/failed chips, retained payload for retry, remove -> best-effort delete, mid-upload removal deletes the orphan on completion, consume() -> AttachmentMetadata with JPEG data-URL previewUrl, discardAllDetached + deinit orphan cleanup (Android onCleared analogue). - ChatInteractor: tray ownership, unsettled chips refuse the send with a notice, attachments-only sends post empty text, optimistic rows carry the metadata, appendDictatedText/postNotice/discardAttachments. - App: AttachmentPreparer (capped security-scoped reads, ImageIO downscale/encode with EXIF transform, HEIC-undecodable fallback), PhotosPicker multi (videos via FileRepresentation temp files), UIImagePickerController camera capture, fileImporter; composer chip row (thumb/spinner/tap-to-retry/remove) with attachment-aware send gating; user bubbles upgrade chips to off-main-decoded previewUrl thumbnails (web-sent attachments included). Dictation (Android B-M3ce port): - HapiProtocol/Models/VoiceApi — TranscriptionResponse, TranscriptionProvidersResponse, TranscriptionProviderInfo. - HapiClient/Endpoints/VoiceEndpoints — GET /api/voice/transcription/providers + multipart POST /api/voice/transcription (file/provider/mode/language, Android part order) over MultipartFormData; DictationTranscribing conformance. - HapiClient/Voice/DictationController — idle/starting/recording/ transcribing, transcribed/noProvider/error events, provider memoized (first standard-capable entry), appendTranscript port. - App: AVAudioRecorderDictation (m4a/AAC mono 44.1 kHz 96 kbps, session activate/deactivate), mic button + recording chip (elapsed + cancel), record-permission request via AVAudioApplication. Info.plist gains NSMicrophoneUsageDescription; the camera string now covers attachment capture (modern PhotosPicker needs no photo-library permission). Tests: policy matrix, tray over the real client with exact base64 upload bodies + gated in-flight scenarios, dictation controller suite with fake recorder/transport, voice endpoint request shapes, and the interactor attachment-send matrix transcribed from the Android VM tests (wire bodies byte-for-byte).
148 lines
6.3 KiB
Swift
148 lines
6.3 KiB
Swift
import Foundation
|
|
|
|
/// Pure sizing/compression policy for composer attachments (A-M3f) — no
|
|
/// UIKit/ImageIO types, so every decision runs under `swift test` on macOS.
|
|
/// Port of the Android `AttachmentPolicy` (B-M3f); constants and decisions
|
|
/// are kept verbatim so the two mobile clients behave identically.
|
|
///
|
|
/// Wire contract (`rest.md` uploads): `POST /api/sessions/:id/upload` is
|
|
/// JSON + base64 with a hard 50 MB decoded limit. The web sends originals
|
|
/// (`attachmentAdapter.ts`); on mobile data that is hostile for camera
|
|
/// photos, so images above ``imageCompressThresholdBytes`` are downscaled to
|
|
/// ``maxImageDimension`` px and re-encoded as JPEG (``compressJPEGQuality``)
|
|
/// before upload. Non-image files always keep their original bytes.
|
|
public enum AttachmentPolicy {
|
|
|
|
/// Hub upload ceiling (decoded bytes) — `MAX_UPLOAD_BYTES` on the web.
|
|
public static let maxUploadBytes = 50 * 1024 * 1024
|
|
|
|
/// Images larger than this get downscaled + JPEG-recompressed.
|
|
public static let imageCompressThresholdBytes = 4 * 1024 * 1024
|
|
|
|
/// Longest edge after downscaling.
|
|
public static let maxImageDimension = 2048
|
|
|
|
/// JPEG quality for recompressed uploads (Android: 85/100).
|
|
public static let compressJPEGQuality = 0.85
|
|
|
|
/// Longest edge of the thumbnail embedded as `AttachmentMetadata.previewUrl`
|
|
/// (a JPEG data URL). The web embeds the full original (≤ 5 MB) there; a
|
|
/// small thumb keeps `SendMessageRequest` bodies tiny while still giving
|
|
/// every client (web included) something to render in the user bubble.
|
|
public static let previewMaxDimension = 512
|
|
|
|
/// JPEG quality for the embedded preview thumbnail (Android: 80/100).
|
|
public static let previewJPEGQuality = 0.80
|
|
|
|
/// Heap guard for reading picked content whose provider reports no size:
|
|
/// recompressible images may legitimately exceed 50 MB pre-compression,
|
|
/// everything else stops at the wire cap (see ``readCap(forMimeType:)``).
|
|
public static let maxImageSourceBytes = 192 * 1024 * 1024
|
|
|
|
/// Formats that are safe to decode + re-encode as a still JPEG. GIFs are
|
|
/// excluded (recompression would drop animation) and SVG/unknown types
|
|
/// are not bitmap-decodable — those upload as originals or get rejected.
|
|
private static let recompressibleImageMimes: Set<String> = [
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/png",
|
|
"image/webp",
|
|
"image/heic",
|
|
"image/heif",
|
|
]
|
|
|
|
/// What the preparer should do with a picked file.
|
|
public enum Plan: Equatable, Sendable {
|
|
/// Upload the original bytes untouched.
|
|
case keepOriginal
|
|
|
|
/// Downscale to ``maxImageDimension`` + JPEG-recompress, then upload.
|
|
case compressImage
|
|
|
|
/// Over the 50 MB wire cap and not recoverable by compression.
|
|
case reject
|
|
}
|
|
|
|
/// Decide the handling for a file of `mimeType` and `sizeBytes`.
|
|
///
|
|
/// Recompressible images over the threshold are compressed even when the
|
|
/// original exceeds 50 MB — downscaling brings any camera photo far under
|
|
/// the cap (the post-compression size is re-checked by the preparer).
|
|
public static func plan(forMimeType mimeType: String, sizeBytes: Int) -> Plan {
|
|
if isRecompressibleImageMime(mimeType), sizeBytes > imageCompressThresholdBytes {
|
|
return .compressImage
|
|
}
|
|
if sizeBytes > maxUploadBytes {
|
|
return .reject
|
|
}
|
|
return .keepOriginal
|
|
}
|
|
|
|
/// How many bytes to read at most before giving up on a pick as too large.
|
|
public static func readCap(forMimeType mimeType: String) -> Int {
|
|
isRecompressibleImageMime(mimeType) ? maxImageSourceBytes : maxUploadBytes
|
|
}
|
|
|
|
public static func isImageMime(_ mimeType: String) -> Bool {
|
|
mimeType.hasPrefix("image/")
|
|
}
|
|
|
|
public static func isRecompressibleImageMime(_ mimeType: String) -> Bool {
|
|
recompressibleImageMimes.contains(mimeType.lowercased())
|
|
}
|
|
|
|
// The Android policy also carries `sampleSizeFor` (a BitmapFactory
|
|
// `inSampleSize` helper); iOS downscales through ImageIO's
|
|
// `kCGImageSourceThumbnailMaxPixelSize`, which takes the target edge
|
|
// directly, so no power-of-two step exists here.
|
|
|
|
/// Final (width, height) with the longest edge clamped to `maxDimension`
|
|
/// (aspect preserved, degenerate edges never collapse below 1).
|
|
public static func scaledDimensions(
|
|
width: Int,
|
|
height: Int,
|
|
maxDimension: Int
|
|
) -> (width: Int, height: Int) {
|
|
let longest = max(width, height)
|
|
if longest <= maxDimension || longest <= 0 {
|
|
return (width, height)
|
|
}
|
|
let scale = Double(maxDimension) / Double(longest)
|
|
let w = max(Int(Double(width) * scale), 1)
|
|
let h = max(Int(Double(height) * scale), 1)
|
|
return (w, h)
|
|
}
|
|
|
|
/// Recompression re-encodes as JPEG, so the advertised filename swaps its
|
|
/// extension to `.jpg` (a `shot.png` upload that is actually JPEG bytes
|
|
/// would confuse the agent reading it from disk).
|
|
public static func compressedFilename(_ original: String) -> String {
|
|
guard let dot = original.lastIndex(of: "."), dot != original.startIndex else {
|
|
return "\(original).jpg"
|
|
}
|
|
return "\(original[original.startIndex..<dot]).jpg"
|
|
}
|
|
|
|
/// `data:<mime>;base64,<...>` — the wire `previewUrl` format (web parity).
|
|
public static func dataUrl(mimeType: String, bytes: Data) -> String {
|
|
"data:\(mimeType);base64,\(bytes.base64EncodedString())"
|
|
}
|
|
|
|
/// The base64 payload of a data URL, or nil when `url` is not one.
|
|
/// Accepts any `data:*;base64,` head — web previews are `data:image/png`
|
|
/// etc., mobile-authored ones are always JPEG.
|
|
public static func base64FromDataUrl(_ url: String) -> String? {
|
|
guard url.hasPrefix("data:") else { return nil }
|
|
guard let comma = url.firstIndex(of: ",") else { return nil }
|
|
guard url[url.startIndex..<comma].hasSuffix(";base64") else { return nil }
|
|
let payload = String(url[url.index(after: comma)...])
|
|
return payload.isEmpty ? nil : payload
|
|
}
|
|
|
|
/// Decoded bytes of a base64 data URL, or nil when unparseable.
|
|
public static func bytesFromDataUrl(_ url: String) -> Data? {
|
|
guard let base64 = base64FromDataUrl(url) else { return nil }
|
|
return Data(base64Encoded: base64)
|
|
}
|
|
}
|