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).
65 lines
2.4 KiB
Swift
65 lines
2.4 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
/// Camera capture for composer attachments (A-M3f): `UIImagePickerController`
|
|
/// restricted to the camera source (`PhotosPicker` has no capture mode), the
|
|
/// Android `TakePicture` analogue. The still is handed back as JPEG bytes and
|
|
/// then flows through the normal `AttachmentPreparer` policy (a >4 MB shot
|
|
/// gets downscaled/recompressed like any other image).
|
|
///
|
|
/// Requires `NSCameraUsageDescription` (present in Info.plist since the QR
|
|
/// pairing scanner). Present inside a `fullScreenCover` and check
|
|
/// ``isAvailable`` first — the Simulator has no camera.
|
|
struct CameraCaptureView: UIViewControllerRepresentable {
|
|
/// JPEG bytes of the capture (already dismissed when called).
|
|
var onCapture: (Data) -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
static var isAvailable: Bool {
|
|
UIImagePickerController.isSourceTypeAvailable(.camera)
|
|
}
|
|
|
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
|
let picker = UIImagePickerController()
|
|
picker.sourceType = .camera
|
|
picker.delegate = context.coordinator
|
|
return picker
|
|
}
|
|
|
|
func updateUIViewController(_ picker: UIImagePickerController, context: Context) {
|
|
context.coordinator.onCapture = onCapture
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(onCapture: onCapture, dismiss: { dismiss() })
|
|
}
|
|
|
|
@MainActor
|
|
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
|
var onCapture: (Data) -> Void
|
|
let dismiss: () -> Void
|
|
|
|
init(onCapture: @escaping (Data) -> Void, dismiss: @escaping () -> Void) {
|
|
self.onCapture = onCapture
|
|
self.dismiss = dismiss
|
|
}
|
|
|
|
func imagePickerController(
|
|
_ picker: UIImagePickerController,
|
|
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
|
|
) {
|
|
let image = info[.originalImage] as? UIImage
|
|
dismiss()
|
|
// Full-quality first encode; the preparer's policy decides whether
|
|
// the result needs the 2048 px recompression pass.
|
|
guard let data = image?.jpegData(compressionQuality: 0.9) else { return }
|
|
onCapture(data)
|
|
}
|
|
|
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|