import AVFoundation import MobileCoreServices import Photos import SwiftUI struct SelectedMedia { let id: String } struct AttachmentMediaPickerView: View { @State private var isCameraAccessGranted = AVCaptureDevice.authorizationStatus(for: .video) == .authorized @State private var isGalleryAccessGranted = PHPhotoLibrary.authorizationStatus() == .authorized @State private var thumbnails = [ThumbnailView]() @State private var selectedMedia = [SelectedMedia]() @State private var showCameraPicker = false let gridSize = UIScreen.main.bounds.width / 3 var body: some View { let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 3) VStack(spacing: 0) { // List of media ScrollView(showsIndicators: false) { LazyVGrid(columns: columns, spacing: 0) { // For camera if isCameraAccessGranted { ZStack { CameraView() .aspectRatio(1, contentMode: .fit) .frame(maxWidth: .infinity) Image(systemName: "camera") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 40, height: 40) .foregroundColor(.white) .padding(8) .background(Color.black.opacity(0.5)) .clipShape(Circle()) .padding(8) } .onTapGesture { showCameraPicker = true } } else { Button { openAppSettings() } label: { ZStack { Rectangle() .fill(Color.Material.Background.light) .overlay { VStack { Image(systemName: "camera") .foregroundColor(.Material.Elements.active) .font(.system(size: 30)) Text("Allow camera access") .foregroundColor(.Material.Text.main) .font(.body3) } } .frame(height: 100) } } } // For gallery if isGalleryAccessGranted { ForEach(thumbnails) { photo in photo } } else { Button { openAppSettings() } label: { ZStack { Rectangle() .fill(Color.Material.Background.light) .overlay { VStack { Image(systemName: "photo") .foregroundColor(.Material.Elements.active) .font(.system(size: 30)) Text("Allow gallery access") .foregroundColor(.Material.Text.main) .font(.body3) } } .frame(height: 100) } } } } } .fullScreenCover(isPresented: $showCameraPicker) { CameraPicker(sourceType: .camera) { data, type in store.dispatch(.conversationAction(.sendAttachment(.init( id: UUID().uuidString, items: [.init(type: type, data: data, string: "")] )))) showCameraPicker = false } .edgesIgnoringSafeArea(.all) } // Send panel Rectangle() .foregroundColor(.Material.Shape.black) .frame(maxWidth: .infinity) .frame(height: self.selectedMedia.isEmpty ? 0 : 50) .overlay { HStack { Text(L10n.Attachment.Send.media) .foregroundColor(.Material.Text.white) .font(.body1) Image(systemName: "arrow.up.circle") .foregroundColor(.Material.Text.white) .font(.body1) .padding(.leading, 8) } .padding() } .clipped() .onTapGesture { let ids = selectedMedia.map { $0.id } sendGalleryMedia(ids: ids) store.dispatch(.conversationAction(.showAttachmentPicker(false))) } } .onAppear { DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 0.2) { checkCameraAccess() checkGalleryAccess() } } } private func checkCameraAccess() { let status = AVCaptureDevice.authorizationStatus(for: .video) switch status { case .authorized: isCameraAccessGranted = true case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { granted in DispatchQueue.main.async { self.isCameraAccessGranted = granted } } case .denied, .restricted: isCameraAccessGranted = false @unknown default: isCameraAccessGranted = false } } private func checkGalleryAccess() { let status = PHPhotoLibrary.authorizationStatus() switch status { case .authorized, .limited: isGalleryAccessGranted = true fetchGallery() case .notDetermined: PHPhotoLibrary.requestAuthorization { status in DispatchQueue.main.async { self.isGalleryAccessGranted = status == .authorized if self.isGalleryAccessGranted { self.fetchGallery() } } } case .denied, .restricted: isGalleryAccessGranted = false @unknown default: isGalleryAccessGranted = false } } private func fetchGallery() { let fetchOptions = PHFetchOptions() fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] let assets = PHAsset.fetchAssets(with: fetchOptions) let manager = PHImageManager.default() let option = PHImageRequestOptions() option.isSynchronous = true assets.enumerateObjects { asset, _, _ in if asset.mediaType == .image { manager.requestImage( for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .aspectFill, options: option ) { image, _ in image?.scaleAndCropImage(toExampleSize: CGSize(width: gridSize, height: gridSize), completion: { image in if let image { DispatchQueue.main.async { self.thumbnails.append(ThumbnailView(id: asset.localIdentifier, image: image, gridSize: gridSize, selected: $selectedMedia)) } } }) } } else if asset.mediaType == .video { manager.requestAVAsset(forVideo: asset, options: nil) { avAsset, _, _ in if let avAsset { let imageGenerator = AVAssetImageGenerator(asset: avAsset) imageGenerator.appliesPreferredTrackTransform = true let time = CMTimeMake(value: 1, timescale: 2) do { let imageRef = try imageGenerator.copyCGImage(at: time, actualTime: nil) let thumbnail = UIImage(cgImage: imageRef) thumbnail.scaleAndCropImage(toExampleSize: CGSize(width: gridSize, height: gridSize), completion: { image in if let image { DispatchQueue.main.async { self.thumbnails.append(ThumbnailView(id: asset.localIdentifier, image: image, gridSize: gridSize, selected: $selectedMedia, duration: asset.duration.minAndSec)) } } }) } catch { print("Failed to create thumbnail image") } } } } } } private func sendGalleryMedia(ids: [String]) { var media: [AttachmentItem] = [] let dispatchGroup = DispatchGroup() let asset = PHAsset.fetchAssets(withLocalIdentifiers: ids, options: nil) asset.enumerateObjects { asset, _, _ in dispatchGroup.enter() if asset.mediaType == .image { let manager = PHImageManager.default() let option = PHImageRequestOptions() option.isSynchronous = true manager.requestImage( for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .aspectFill, options: option ) { image, _ in if let image { let data = image.jpegData(compressionQuality: 1.0) ?? Data() media.append(.init(type: .image, data: data, string: "")) } dispatchGroup.leave() } } else if asset.mediaType == .video { let manager = PHImageManager.default() let option = PHVideoRequestOptions() option.version = .current option.deliveryMode = .highQualityFormat manager.requestAVAsset(forVideo: asset, options: option) { avAsset, _, _ in if let avAsset { let url = (avAsset as? AVURLAsset)?.url let data = try? Data(contentsOf: url ?? URL(fileURLWithPath: "")) media.append(.init(type: .movie, data: data ?? Data(), string: "")) } dispatchGroup.leave() } } } dispatchGroup.notify(queue: .main) { store.dispatch(.conversationAction(.sendAttachment(.init( id: UUID().uuidString, items: media )))) } } } private struct ThumbnailView: Identifiable, View { let id: String let gridSize: CGFloat let duration: String? @State private var image: UIImage @State private var ready = false @State private var selected = false @Binding var selectedMedia: [SelectedMedia] init(id: String, image: UIImage, gridSize: CGFloat, selected: Binding<[SelectedMedia]>, duration: String? = nil) { self.id = id self.image = image self.gridSize = gridSize _selectedMedia = selected self.duration = duration } var body: some View { if ready { ZStack { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) .frame(width: gridSize, height: gridSize) .clipped() if let duration { VStack { Spacer() HStack { Spacer() Text(duration) .foregroundColor(.Material.Text.white) .font(.sub1) .shadow(color: .black, radius: 2) .padding(4) } } } if selected { VStack { HStack { Spacer() Circle() .frame(width: 30, height: 30) .shadow(color: .black, radius: 2) .foregroundColor(.Material.Shape.white) .overlay { Image(systemName: "checkmark") .foregroundColor(.Material.Elements.active) .font(.body3) } .padding(4) } Spacer() } } } .onTapGesture { withAnimation { selected.toggle() if selected { selectedMedia.append(SelectedMedia(id: id)) } else { selectedMedia.removeAll { $0.id == id } } } } } else { ZStack { Rectangle() .fill(Color.Material.Background.light) .overlay { ProgressView() } .frame(width: gridSize, height: gridSize) } .onAppear { image.scaleAndCropImage(toExampleSize: CGSize(width: gridSize, height: gridSize), completion: { image in if let image { self.image = image ready = true } }) } } } } class CameraUIView: UIView { var previewLayer: AVCaptureVideoPreviewLayer? override func layoutSubviews() { super.layoutSubviews() previewLayer?.frame = bounds } } struct CameraView: UIViewRepresentable { func makeUIView(context _: Context) -> CameraUIView { let view = CameraUIView() let captureSession = AVCaptureSession() guard let captureDevice = AVCaptureDevice.default(for: .video) else { return view } guard let input = try? AVCaptureDeviceInput(device: captureDevice) else { return view } captureSession.addInput(input) let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) previewLayer.videoGravity = .resizeAspectFill view.layer.addSublayer(previewLayer) view.previewLayer = previewLayer captureSession.startRunning() return view } func updateUIView(_ uiView: CameraUIView, context _: Context) { uiView.previewLayer?.frame = uiView.bounds } } struct CameraPicker: UIViewControllerRepresentable { var sourceType: UIImagePickerController.SourceType var completionHandler: (Data, AttachmentType) -> Void func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.sourceType = sourceType picker.delegate = context.coordinator picker.mediaTypes = [UTType.movie.identifier, UTType.image.identifier] picker.videoQuality = .typeHigh picker.videoMaximumDuration = 60 // 60 sec Limit picker.view.backgroundColor = .clear return picker } func updateUIViewController(_: UIImagePickerController, context _: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { let parent: CameraPicker init(_ parent: CameraPicker) { self.parent = parent } func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { // swiftlint:disable:next force_cast let mediaType = info[.mediaType] as! String if mediaType == UTType.image.identifier { if let image = info[.originalImage] as? UIImage { let data = image.jpegData(compressionQuality: 1.0) ?? Data() parent.completionHandler(data, .image) } } else if mediaType == UTType.movie.identifier { if let url = info[.mediaURL] as? URL { let data = try? Data(contentsOf: url) parent.completionHandler(data ?? Data(), .movie) } } } } }