Files

250 lines
9.1 KiB
Swift

import AVFoundation
import PhotosUI
import SwiftUI
import UIKit
import UniformTypeIdentifiers
// MARK: - System PHPicker
enum ImagePickerResult {
case cancelled
case failed(String)
case picked(Data, filename: String)
case pickedImages([(data: Data, filename: String)])
}
enum CameraCaptureNextStep: Equatable {
case openCamera
case requestPermission
case showSettings
case unavailable
}
enum CameraCaptureAccess {
static func nextStep(
isCameraAvailable: Bool,
authorizationStatus: AVAuthorizationStatus
) -> CameraCaptureNextStep {
guard isCameraAvailable else { return .unavailable }
switch authorizationStatus {
case .authorized:
return .openCamera
case .notDetermined:
return .requestPermission
case .denied, .restricted:
return .showSettings
@unknown default:
return .showSettings
}
}
}
/// UIKit PHPicker wrapper — loads UIImage/data via NSItemProvider (not Transferable).
struct SystemImagePicker: UIViewControllerRepresentable {
var onFinish: (ImagePickerResult) -> Void
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration(photoLibrary: .shared())
config.filter = .images
config.selectionLimit = 20
config.selection = .ordered
config.preferredAssetRepresentationMode = .current
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onFinish: onFinish)
}
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
let onFinish: (ImagePickerResult) -> Void
private var settled = false
init(onFinish: @escaping (ImagePickerResult) -> Void) {
self.onFinish = onFinish
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard !settled else { return }
guard !results.isEmpty else {
settled = true
DispatchQueue.main.async { self.onFinish(.cancelled) }
return
}
Task {
do {
var images: [(data: Data, filename: String)] = []
for result in results {
let (data, name) = try await Self.loadImage(from: result.itemProvider)
images.append((data: data, filename: name))
}
await MainActor.run {
guard !self.settled else { return }
self.settled = true
self.onFinish(.pickedImages(images))
}
} catch {
await MainActor.run {
guard !self.settled else { return }
self.settled = true
self.onFinish(.failed(error.localizedDescription))
}
}
}
}
private static func loadImage(from provider: NSItemProvider) async throws -> (Data, String) {
// 1) UIImage path — most reliable for Photos library assets.
if provider.canLoadObject(ofClass: UIImage.self) {
let image = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<UIImage, Error>) in
provider.loadObject(ofClass: UIImage.self) { object, error in
if let image = object as? UIImage {
cont.resume(returning: image)
} else {
cont.resume(
throwing: error
?? APIError(status: 0, code: nil, message: "无法解码图片")
)
}
}
}
if let data = image.jpegData(compressionQuality: 0.92), !data.isEmpty {
return (data, "image.jpg")
}
}
// 2) Typed data representations.
let typeIds = [
UTType.jpeg.identifier,
UTType.png.identifier,
UTType.heic.identifier,
UTType.image.identifier,
]
for typeId in typeIds where provider.hasItemConformingToTypeIdentifier(typeId) {
if let data = try? await loadData(provider, typeIdentifier: typeId), !data.isEmpty {
let normalized = ImagePickerData.normalize(data)
let mime = TipTapResourceLoader.sniffImageMime(normalized)
let ext = mime == "image/png" ? "png" : "jpg"
return (normalized, "image.\(ext)")
}
}
throw APIError(status: 0, code: nil, message: "无法读取所选图片,请换一张重试。")
}
private static func loadData(_ provider: NSItemProvider, typeIdentifier: String) async throws -> Data {
try await withCheckedThrowingContinuation { cont in
provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, error in
if let data {
cont.resume(returning: data)
} else {
cont.resume(
throwing: error
?? APIError(status: 0, code: nil, message: "读取图片数据失败")
)
}
}
}
}
}
}
// MARK: - System camera
/// UIKit camera wrapper. Camera capture is intentionally separate from PHPicker:
/// PHPicker only reads the photo library and cannot launch the camera directly.
struct SystemCameraPicker: UIViewControllerRepresentable {
var onFinish: (ImagePickerResult) -> Void
static var isAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.allowsEditing = false
guard Self.isAvailable else {
DispatchQueue.main.async {
context.coordinator.finish(.failed("此设备没有可用相机。"))
}
return picker
}
picker.sourceType = .camera
picker.mediaTypes = [UTType.image.identifier]
picker.cameraCaptureMode = .photo
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onFinish: onFinish)
}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onFinish: (ImagePickerResult) -> Void
private var settled = false
init(onFinish: @escaping (ImagePickerResult) -> Void) {
self.onFinish = onFinish
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
finish(.cancelled)
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
guard let image = info[.originalImage] as? UIImage,
let data = image.jpegData(compressionQuality: 0.92),
!data.isEmpty
else {
finish(.failed("无法读取拍摄的照片,请重试。"))
return
}
finish(.picked(data, filename: ImagePickerData.cameraFilename()))
}
func finish(_ result: ImagePickerResult) {
guard !settled else { return }
settled = true
onFinish(result)
}
}
}
enum ImagePickerData {
static func cameraFilename(at date: Date = Date()) -> String {
let timestamp = ISO8601DateFormatter()
.string(from: date)
.replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: ":", with: "")
return "photo-\(timestamp).jpg"
}
/// HEIC / unknown → JPEG so upload mime is valid and ImageCompressor can decode.
static func normalize(_ data: Data) -> Data {
if data.starts(with: [0xFF, 0xD8, 0xFF]) { return data } // JPEG
if data.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return data } // PNG
if data.starts(with: [0x47, 0x49, 0x46, 0x38]) { return data } // GIF
if data.count >= 12 {
let riff = data.prefix(4)
let webp = data.dropFirst(8).prefix(4)
if riff.elementsEqual([0x52, 0x49, 0x46, 0x46]), webp.elementsEqual([0x57, 0x45, 0x42, 0x50]) {
return data
}
}
if let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.92) {
return jpeg
}
return data
}
}