Files
hapi/ios/Hapi/Features/Chat/Attachments/AttachmentPreparer.swift
T
weishu 05d063cc33 feat(ios): composer attachments + dictation (A-M3f)
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).
2026-08-18 11:27:28 +08:00

251 lines
9.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreGraphics
import Foundation
import HapiClient
import ImageIO
import UniformTypeIdentifiers
/// Outcome of preparing one picked/captured file for upload.
enum PrepareResult {
case ready(PreparedAttachment)
/// Over the 50 MB wire cap (and not recoverable by image compression).
case tooLarge(filename: String, sizeBytes: Int)
/// The source would not give us the bytes.
case unreadable(filename: String)
}
/// Reads picked content into `PreparedAttachment`s ready for
/// `ComposerAttachments.add` (A-M3f). All policy decisions live in the pure
/// `AttachmentPolicy` (HapiClient); this enum supplies the platform parts:
/// security-scoped file reads, ImageIO downscaling (`CGImageSource`
/// thumbnailing honors EXIF orientation), JPEG re-encode, and thumbnail
/// generation. Port of the Android `AttachmentPreparer`.
///
/// Compression stance (differs from web, which uploads originals): images
/// over `AttachmentPolicy.imageCompressThresholdBytes` are downscaled to
/// `AttachmentPolicy.maxImageDimension` px JPEG — phone photos are 5–15 MB
/// of mostly-wasted agent context on mobile data. Non-image files always
/// keep their original bytes; anything still over 50 MB is rejected.
///
/// Every function is `nonisolated` (enum statics) and the entry points are
/// async, so decode/encode work hops off the main actor at the call site.
enum AttachmentPreparer {
// MARK: - Entry points
/// In-memory bytes (photo picker loads, camera captures): apply the
/// policy and build the prepared attachment.
static func prepare(data: Data, filename: String, mimeType: String) async -> PrepareResult {
prepareBytes(filename: filename, mimeType: mimeType, original: data)
}
/// One `fileImporter` pick. The URL is security-scoped; the read happens
/// behind a cap so a surprise multi-GB pick cannot exhaust memory.
static func prepare(fileURL: URL) async -> PrepareResult {
let filename = fileURL.lastPathComponent
let mimeType = mimeType(forFilename: filename)
let scoped = fileURL.startAccessingSecurityScopedResource()
defer {
if scoped { fileURL.stopAccessingSecurityScopedResource() }
}
// Reject before reading when the filesystem already says it's hopeless.
let statedSize = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]))?.fileSize
if let statedSize,
AttachmentPolicy.plan(forMimeType: mimeType, sizeBytes: statedSize) == .reject {
return .tooLarge(filename: filename, sizeBytes: statedSize)
}
let cap = AttachmentPolicy.readCap(forMimeType: mimeType)
guard let read = readUpTo(cap: cap, url: fileURL) else {
return .unreadable(filename: filename)
}
if read.overflowed {
return .tooLarge(filename: filename, sizeBytes: statedSize ?? cap)
}
return prepareBytes(filename: filename, mimeType: mimeType, original: read.bytes)
}
// MARK: - Naming helpers (used by the picker UI)
/// Filename + MIME for a photo-picker item, derived from its content
/// type (the picker does not expose original filenames).
static func photoFilename(for type: UTType?) -> (filename: String, mimeType: String) {
let mime = type?.preferredMIMEType ?? "image/jpeg"
let ext = type?.preferredFilenameExtension ?? "jpg"
return ("photo-\(timestamp()).\(ext)", mime)
}
/// Filename for a fresh camera capture (always JPEG-encoded by us).
static func cameraFilename() -> String {
"camera-\(timestamp()).jpg"
}
private static func timestamp() -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd-HHmmss"
return formatter.string(from: Date())
}
private static func mimeType(forFilename filename: String) -> String {
let ext = (filename as NSString).pathExtension
guard !ext.isEmpty,
let type = UTType(filenameExtension: ext.lowercased()),
let mime = type.preferredMIMEType else {
return "application/octet-stream"
}
return mime
}
// MARK: - Capped read
private struct CappedRead {
let bytes: Data
let overflowed: Bool
}
/// Read the full file, or stop with `overflowed` once `cap` bytes are
/// exceeded (64 KB chunks, Android `readUpTo` twin).
private static func readUpTo(cap: Int, url: URL) -> CappedRead? {
guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
defer { try? handle.close() }
var out = Data()
do {
while true {
guard let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty else {
break // EOF
}
out.append(chunk)
if out.count > cap {
return CappedRead(bytes: Data(), overflowed: true)
}
}
} catch {
return nil
}
return CappedRead(bytes: out, overflowed: false)
}
// MARK: - Policy application
/// Policy application over in-memory bytes (shared by every pick source).
private static func prepareBytes(filename: String, mimeType: String, original: Data) -> PrepareResult {
switch AttachmentPolicy.plan(forMimeType: mimeType, sizeBytes: original.count) {
case .reject:
return .tooLarge(filename: filename, sizeBytes: original.count)
case .keepOriginal:
return .ready(PreparedAttachment(
filename: filename,
mimeType: mimeType,
bytes: original,
previewBytes: AttachmentPolicy.isImageMime(mimeType) ? thumbnail(original) : nil
))
case .compressImage:
if let compressed = recompress(original) {
return .ready(PreparedAttachment(
filename: AttachmentPolicy.compressedFilename(filename),
mimeType: "image/jpeg",
bytes: compressed,
previewBytes: thumbnail(compressed)
))
}
// Undecodable: fall back to the original when it fits the wire
// cap, reject otherwise (Android parity).
if original.count <= AttachmentPolicy.maxUploadBytes {
return .ready(PreparedAttachment(
filename: filename,
mimeType: mimeType,
bytes: original,
previewBytes: thumbnail(original)
))
}
return .tooLarge(filename: filename, sizeBytes: original.count)
}
}
/// Downscale to ≤ `maxImageDimension` px JPEG, or nil when undecodable /
/// still too big.
private static func recompress(_ original: Data) -> Data? {
guard let encoded = encodeScaledJPEG(
original,
maxDimension: AttachmentPolicy.maxImageDimension,
quality: AttachmentPolicy.compressJPEGQuality
) else {
return nil
}
// Belt-and-braces: a 2048 px JPEG is always far under 50 MB, but the
// wire cap is a hard contract.
return encoded.count <= AttachmentPolicy.maxUploadBytes ? encoded : nil
}
/// Chip/bubble thumbnail (also the wire `previewUrl` payload); nil when
/// undecodable.
static func thumbnail(_ imageBytes: Data) -> Data? {
encodeScaledJPEG(
imageBytes,
maxDimension: AttachmentPolicy.previewMaxDimension,
quality: AttachmentPolicy.previewJPEGQuality
)
}
// MARK: - ImageIO
/// Decode + downscale (longest edge ≤ `maxDimension`, EXIF orientation
/// applied) + JPEG-encode. Nil when the bytes are not a decodable image.
static func encodeScaledJPEG(_ source: Data, maxDimension: Int, quality: Double) -> Data? {
guard let imageSource = CGImageSourceCreateWithData(source as CFData, nil),
CGImageSourceGetCount(imageSource) > 0 else {
return nil
}
let thumbnailOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension,
]
guard let scaled = CGImageSourceCreateThumbnailAtIndex(
imageSource,
0,
thumbnailOptions as CFDictionary
) else {
return nil
}
let out = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
out as CFMutableData,
UTType.jpeg.identifier as CFString,
1,
nil
) else {
return nil
}
let encodeOptions: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality,
]
CGImageDestinationAddImage(destination, scaled, encodeOptions as CFDictionary)
guard CGImageDestinationFinalize(destination) else { return nil }
return out as Data
}
/// Downscale-only decode for rendering `previewUrl` payloads (bubble
/// thumbnails). Web-authored previews embed the full original (up to
/// 5 MB), so the decode clamps to `maxDimension` and should be called off
/// the main actor.
static func decodeDownsampled(_ bytes: Data, maxDimension: Int) -> CGImage? {
guard let imageSource = CGImageSourceCreateWithData(bytes as CFData, nil),
CGImageSourceGetCount(imageSource) > 0 else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension,
]
return CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary)
}
}