Files
hapi/ios/Hapi/Features/Chat/Attachments/CameraCaptureView.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

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()
}
}
}