From 7ded0606e58b32f0c53530e4aa1e07e52a37d7e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rasmus=20W=C3=B8ldike?= Date: Tue, 7 Apr 2026 14:54:40 +0200 Subject: [PATCH] feat(camera): add in-app camera with direct upload to Nextcloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a custom AVFoundation camera that captures photos and videos and uploads them directly to Nextcloud without saving to the device camera roll by default. - Custom camera UI: shutter, flash, flip, mode selector (photo/video) - Pinch-to-zoom from 0.5x (ultra-wide) to 10x with live zoom label - Virtual multi-camera support (triple/dual-wide) for sub-1x zoom - Recording timer with red dot, screen stays on during video recording - App backgrounding gracefully stops recording in progress - Review screen after capture: retake or use, inline video playback - "Save to camera roll" opt-in toggle per session (off by default) - Global default for toggle in Settings -> Advanced - Filenames follow Nextcloud conventions via createFileName(), including "Maintain original filename" mode (IMG_XXXX.JPG) - Videos saved as .mov (QuickTime) matching Apple native camera format - Restored live photo upload logic: livePhotoFile and nativeFormat were inadvertently removed in a previous refactor - Fix: PHAuthorizationStatus.limited now correctly treated as authorized - Fix: NCViewerQuickLookView crash when asset is nil after model change Signed-off-by: Rasmus Wøldike --- .../Upload Assets/NCUploadAssetsModel.swift | 427 +++++--- .../Upload Assets/NCUploadAssetsView.swift | 39 +- iOSClient/Main/NCPickerViewController.swift | 911 +++++++++++++++--- .../Advanced/NCSettingsAdvancedModel.swift | 9 + .../Advanced/NCSettingsAdvancedView.swift | 14 + iOSClient/Settings/NCPreferences.swift | 16 +- .../da.lproj/Localizable.strings | Bin 99384 -> 94810 bytes .../en.lproj/Localizable.strings | 44 +- iOSClient/Utility/NCAskAuthorization.swift | 5 +- .../NCViewerQuickLookView.swift | 7 +- 10 files changed, 1116 insertions(+), 356 deletions(-) diff --git a/iOSClient/Main/Create/Upload Assets/NCUploadAssetsModel.swift b/iOSClient/Main/Create/Upload Assets/NCUploadAssetsModel.swift index 04f9521bd1..280645911f 100644 --- a/iOSClient/Main/Create/Upload Assets/NCUploadAssetsModel.swift +++ b/iOSClient/Main/Create/Upload Assets/NCUploadAssetsModel.swift @@ -1,30 +1,91 @@ // SPDX-FileCopyrightText: Nextcloud GmbH // SPDX-FileCopyrightText: 2023 Marino Faggiana +// SPDX-FileCopyrightText: 2026 Rasmus Wøldike // SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI import NextcloudKit import TLPhotoPicker -import Mantis import Photos import QuickLook -// MARK: - Class +// MARK: - CameraAssets helper +enum CameraAssets { + struct TempAsset { + let fileURL: URL + let fileName: String + let isVideo: Bool + } +} + +// MARK: - PreviewStore struct PreviewStore { var id: String - var asset: TLPHAsset + var asset: TLPHAsset? var assetType: TLPHAsset.AssetType var uti: String? var nativeFormat: Bool var data: Data? var fileName: String var image: UIImage? + var tempURL: URL? } +// MARK: - NCUploadAssetsModel class NCUploadAssetsModel: ObservableObject, NCCreateFormUploadConflictDelegate { + func dismissCreateFormUploadConflict(metadatas: [tableMetadata]?) { + guard let metadatas = metadatas else { + self.showHUD = false + self.uploadInProgress.toggle() + return + } + let autoMkcol = capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersion33 + func createProcessUploads() { + if !self.dismissView { + self.database.addMetadatas(metadatas) + if self.saveToCameraRoll && !self.tempAssets.isEmpty { + self.saveTempAssetsToCameraRoll() + } + self.dismissView = true + } + } + + if !autoMkcol, useAutoUploadFolder { + let assets = self.assets.compactMap { $0.phAsset } + NCManageDatabaseCreateMetadata().createMetadatasFolder( + assets: assets, + useSubFolder: self.useAutoUploadSubFolder, + session: self.session + ) { metadatasFolder in + self.database.addMetadatas(metadatasFolder) + self.showHUD = false + createProcessUploads() + } + } else { + createProcessUploads() + } + } + + + private func saveTempAssetsToCameraRoll() { + for url in tempAssets { + let ext = url.pathExtension.lowercased() + if ["mov", "mp4", "m4v"].contains(ext) { + PHPhotoLibrary.shared().performChanges({ + PHAssetCreationRequest.creationRequestForAssetFromVideo(atFileURL: url) + }, completionHandler: nil) + } else if let data = try? Data(contentsOf: url) { + PHPhotoLibrary.shared().performChanges({ + PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil) + }, completionHandler: nil) + } + } + } + + // MARK: - Published @Published var serverUrl: String - @Published var assets: [TLPHAsset] + @Published var assets: [TLPHAsset] = [] @Published var previewStore: [PreviewStore] = [] @Published var dismissView = false @Published var hiddenSave = true @@ -32,24 +93,29 @@ class NCUploadAssetsModel: ObservableObject, NCCreateFormUploadConflictDelegate @Published var useAutoUploadSubFolder = false @Published var showHUD = false @Published var uploadInProgress = false - // Root View Controller @Published var controller: NCMainTabBarController? - // Keychain access + @Published var saveToCameraRoll: Bool = false + + // MARK: - Private var keychain = NCPreferences() - // Session + let database = NCManageDatabase.shared + let global = NCGlobal.shared + var timer: Timer? + var metadatasNOConflict: [tableMetadata] = [] + var metadatasUploadInConflict: [tableMetadata] = [] + var tempAssets: [URL] = [] + + // MARK: - Session / Capabilities var session: NCSession.Session { NCSession.shared.getSession(controller: controller) } - // Capabilities + var capabilities: NKCapabilities.Capabilities { NCNetworking.shared.capabilities[controller?.account ?? ""] ?? NKCapabilities.Capabilities() } - let database = NCManageDatabase.shared - let global = NCGlobal.shared - var metadatasNOConflict: [tableMetadata] = [] - var metadatasUploadInConflict: [tableMetadata] = [] - var timer: Timer? - + + // MARK: - Initializers + init(assets: [TLPHAsset], serverUrl: String, controller: NCMainTabBarController?) { self.assets = assets self.serverUrl = serverUrl @@ -61,224 +127,285 @@ class NCUploadAssetsModel: ObservableObject, NCCreateFormUploadConflictDelegate for asset in self.assets { var uti: String? - // Must be in primary Task - // if let phAsset = asset.phAsset, let resource = PHAssetResource.assetResources(for: phAsset).first(where: { $0.type == .photo }) { uti = resource.uniformTypeIdentifier } - guard let localIdentifier = asset.phAsset?.localIdentifier - else { - continue - } + guard let localIdentifier = asset.phAsset?.localIdentifier else { continue } + + self.previewStore.append( + PreviewStore( + id: localIdentifier, + asset: asset, + assetType: asset.type, + uti: uti, + nativeFormat: !NCPreferences().formatCompatibility, + data: nil, + fileName: "", + image: nil + ) + ) + } + + self.hiddenSave = false + } - self.previewStore.append(PreviewStore(id: localIdentifier, asset: asset, assetType: asset.type, uti: uti, nativeFormat: !NCPreferences().formatCompatibility, fileName: "")) + init(tempAssets: [URL], serverUrl: String, controller: NCMainTabBarController?) { + self.assets = [] + self.tempAssets = tempAssets + self.serverUrl = serverUrl + self.controller = controller + self.saveToCameraRoll = NCPreferences().saveCameraMediaToCameraRoll + + self.useAutoUploadFolder = keychain.getUploadUseAutoUploadFolder(account: session.account) + self.useAutoUploadSubFolder = keychain.getUploadUseAutoUploadSubFolder(account: session.account) + self.previewStore = tempAssets.map { url in + PreviewStore( + id: UUID().uuidString, + asset: nil, + assetType: .photo, + uti: nil, + nativeFormat: true, + data: try? Data(contentsOf: url), + fileName: url.lastPathComponent, + image: UIImage(contentsOfFile: url.path), + tempURL: url + ) } self.hiddenSave = false } + + + // MARK: - Timer (QuickLook) + func startTimer(navigationItem: UINavigationItem) { + self.timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + guard let buttonDone = navigationItem.leftBarButtonItems?.first, + let buttonCrop = navigationItem.leftBarButtonItems?.last else { return } - func updateUseAutoUploadFolder() { - keychain.setUploadUseAutoUploadFolder(account: session.account, value: useAutoUploadFolder) - } + buttonCrop.isEnabled = true + buttonDone.isEnabled = true - func updateUseAutoUploadSubFolder() { - keychain.setUploadUseAutoUploadSubFolder(account: session.account, value: useAutoUploadSubFolder) + if let markup = navigationItem.rightBarButtonItems?.first(where: { $0.accessibilityIdentifier == "QLOverlayMarkupButtonAccessibilityIdentifier" }), + let originalButton = markup.value(forKey: "originalButton") as AnyObject?, + let symbolImageName = originalButton.value(forKey: "symbolImageName") as? String, + symbolImageName == "pencil.tip.crop.circle.on" { + buttonCrop.isEnabled = false + buttonDone.isEnabled = false + } + } } - func getTextServerUrl() -> String { - if let directory = database.getTableDirectory(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@", session.account, serverUrl)), let metadata = database.getMetadataFromOcId(directory.ocId) { - return (metadata.fileNameView) - } else { - return (serverUrl as NSString).lastPathComponent - } + func stopTimer() { + self.timer?.invalidate() + self.timer = nil } + // MARK: - Helpers + func lowResolutionImage(asset: PHAsset) -> UIImage? { let imageManager = PHImageManager.default() let options = PHImageRequestOptions() options.isSynchronous = true options.resizeMode = .fast options.isNetworkAccessAllowed = true - let targetSize = CGSize(width: 80, height: 80) var thumbnail: UIImage? - - // Must be in primary Task - // imageManager.requestImage(for: asset, targetSize: targetSize, contentMode: .aspectFill, options: options) { result, _ in thumbnail = result } - return thumbnail } - func deleteAsset(index: Int) { - assets.remove(at: index) - previewStore.remove(at: index) - if previewStore.isEmpty { - dismissView = true - } - } - func presentedQuickLook(index: Int, fileNamePath: String) -> Bool { var image: UIImage? - if let imageData = previewStore[index].data { image = UIImage(data: imageData) - } else if let imageFullResolution = previewStore[index].asset.fullResolutionImage?.fixedOrientation() { + } else if let imageFullResolution = previewStore[index].asset?.fullResolutionImage?.fixedOrientation() { image = imageFullResolution + } else if let tempURL = previewStore[index].tempURL { + image = UIImage(contentsOfFile: tempURL.path) } - if let image = image { - if let data = image.jpegData(compressionQuality: 1) { - do { - try data.write(to: URL(fileURLWithPath: fileNamePath)) - return true - } catch { - } - } + if let image, + let data = image.jpegData(compressionQuality: 1) { + try? data.write(to: URL(fileURLWithPath: fileNamePath)) + return true } return false } - func startTimer(navigationItem: UINavigationItem) { - self.timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { _ in - guard let buttonDone = navigationItem.leftBarButtonItems?.first, let buttonCrop = navigationItem.leftBarButtonItems?.last else { return } - buttonCrop.isEnabled = true - buttonDone.isEnabled = true - if let markup = navigationItem.rightBarButtonItems?.first(where: { $0.accessibilityIdentifier == "QLOverlayMarkupButtonAccessibilityIdentifier" }) { - if let originalButton = markup.value(forKey: "originalButton") as AnyObject? { - if let symbolImageName = originalButton.value(forKey: "symbolImageName") as? String { - if symbolImageName == "pencil.tip.crop.circle.on" { - buttonCrop.isEnabled = false - buttonDone.isEnabled = false - } - } - } - } - }) + func deleteAsset(index: Int) { + guard index < previewStore.count else { return } + previewStore.remove(at: index) + if previewStore.isEmpty { dismissView = true } } - func stopTimer() { - self.timer?.invalidate() - self.timer = nil + func updateUseAutoUploadFolder() { + keychain.setUploadUseAutoUploadFolder(account: session.account, value: useAutoUploadFolder) } - func dismissCreateFormUploadConflict(metadatas: [tableMetadata]?) { - guard let metadatas = metadatas else { - self.showHUD = false - self.uploadInProgress.toggle() - return - } - let autoMkcol = capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersion33 - - func createProcessUploads() { - if !self.dismissView { - self.database.addMetadatas(metadatas) - self.dismissView = true - } - } + func updateUseAutoUploadSubFolder() { + keychain.setUploadUseAutoUploadSubFolder(account: session.account, value: useAutoUploadSubFolder) + } - if !autoMkcol, - useAutoUploadFolder { - let assets = self.assets.compactMap { $0.phAsset } - NCManageDatabaseCreateMetadata().createMetadatasFolder(assets: assets, useSubFolder: self.useAutoUploadSubFolder, session: self.session) { metadatasFolder in - self.database.addMetadatas(metadatasFolder) - self.showHUD = false - createProcessUploads() - } - } else { - createProcessUploads() + func getTextServerUrl() -> String { + if let directory = database.getTableDirectory(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@", session.account, serverUrl)), + let metadata = database.getMetadataFromOcId(directory.ocId) { + return metadata.fileNameView } + return (serverUrl as NSString).lastPathComponent } - + func save(completion: @escaping (_ metadatasNOConflict: [tableMetadata], _ metadatasUploadInConflict: [tableMetadata]) -> Void) { Task { @MainActor in + let utilityFileSystem = NCUtilityFileSystem() var metadatasNOConflict: [tableMetadata] = [] var metadatasUploadInConflict: [tableMetadata] = [] + let autoUploadServerUrlBase = database.getAccountAutoUploadServerUrlBase(session: self.session) - var serverUrl = useAutoUploadFolder ? autoUploadServerUrlBase : serverUrl + var serverUrl = useAutoUploadFolder ? autoUploadServerUrlBase : self.serverUrl let isInDirectoryE2EE = NCUtilityFileSystem().isDirectoryE2EE(serverUrl: serverUrl, urlBase: session.urlBase, userId: session.userId, account: session.account) for tlAsset in assets { - guard let asset = tlAsset.phAsset, let previewStore = previewStore.first(where: { $0.id == asset.localIdentifier }) else { continue } + + guard let asset = tlAsset.phAsset, + let preview = previewStore.first(where: { $0.id == asset.localIdentifier }) else { continue } + let assetFileName = asset.originalFilename - var livePhoto: Bool = false let creationDate = asset.creationDate ?? Date() let ext = (assetFileName as NSString).pathExtension.lowercased() - let fileName = previewStore.fileName.isEmpty ? utilityFileSystem.createFileName(assetFileName as String, fileDate: creationDate, fileType: asset.mediaType) - : (previewStore.fileName + "." + ext) - - if previewStore.assetType == .livePhoto, - !isInDirectoryE2EE, - NCPreferences().livePhoto, - previewStore.data == nil { - livePhoto = true - } + let fileName = preview.fileName.isEmpty + ? utilityFileSystem.createFileName(assetFileName, fileDate: creationDate, fileType: asset.mediaType) + : (preview.fileName + "." + ext) + + let livePhoto = preview.assetType == .livePhoto + && !isInDirectoryE2EE + && NCPreferences().livePhoto + && preview.data == nil - // Auto upload with subfolder if useAutoUploadSubFolder { serverUrl = utilityFileSystem.createGranularityPath(asset: asset, serverUrlBase: autoUploadServerUrlBase) } - // Check if is in upload let predicate = NSPredicate(format: "account == %@ AND serverUrl == %@ AND fileName == %@ AND session != ''", - session.account, - serverUrl, - fileName) - if let results = database.getMetadatas(predicate: predicate, - sortedByKeyPath: "fileName", - ascending: false), !results.isEmpty { + session.account, serverUrl, fileName) + if let results = database.getMetadatas(predicate: predicate, sortedByKeyPath: "fileName", ascending: false), + !results.isEmpty { continue } - let metadataForUpload = await NCManageDatabaseCreateMetadata().createMetadataAsync( + let metadata = await NCManageDatabaseCreateMetadata().createMetadataAsync( fileName: fileName, - ocId: NSUUID().uuidString, + ocId: UUID().uuidString, serverUrl: serverUrl, session: session, - sceneIdentifier: controller?.sceneIdentifier) + sceneIdentifier: controller?.sceneIdentifier + ) if livePhoto { - metadataForUpload.livePhotoFile = (metadataForUpload.fileName as NSString).deletingPathExtension + ".mov" + metadata.livePhotoFile = (metadata.fileName as NSString).deletingPathExtension + ".mov" } - metadataForUpload.assetLocalIdentifier = asset.localIdentifier - metadataForUpload.session = NCNetworking.shared.sessionUploadBackground - metadataForUpload.sessionSelector = self.global.selectorUploadFile - metadataForUpload.status = self.global.metadataStatusWaitUpload - metadataForUpload.sessionDate = Date() - metadataForUpload.nativeFormat = previewStore.nativeFormat - - if let previewStore = self.previewStore.first(where: { $0.id == asset.localIdentifier }), - let data = previewStore.data { - if metadataForUpload.contentType == "image/heic" { + metadata.assetLocalIdentifier = asset.localIdentifier + metadata.session = NCNetworking.shared.sessionUploadBackground + metadata.sessionSelector = global.selectorUploadFile + metadata.status = global.metadataStatusWaitUpload + metadata.sessionDate = Date() + metadata.nativeFormat = preview.nativeFormat + + if let data = preview.data { + if metadata.contentType == "image/heic" { let fileNameNoExtension = (fileName as NSString).deletingPathExtension - metadataForUpload.contentType = "image/jpeg" - metadataForUpload.fileName = fileNameNoExtension + ".jpg" - metadataForUpload.fileNameView = fileNameNoExtension + ".jpg" - metadataForUpload.nativeFormat = false + metadata.contentType = "image/jpeg" + metadata.fileName = fileNameNoExtension + ".jpg" + metadata.fileNameView = fileNameNoExtension + ".jpg" + metadata.nativeFormat = false } - let fileNamePath = utilityFileSystem.getDirectoryProviderStorageOcId(metadataForUpload.ocId, - fileName: metadataForUpload.fileNameView, - userId: metadataForUpload.userId, - urlBase: metadataForUpload.urlBase) + let fileNamePath = utilityFileSystem.getDirectoryProviderStorageOcId( + metadata.ocId, + fileName: metadata.fileNameView, + userId: metadata.userId, + urlBase: metadata.urlBase + ) do { try data.write(to: URL(fileURLWithPath: fileNamePath)) - metadataForUpload.isExtractFile = true - metadataForUpload.size = utilityFileSystem.getFileSize(filePath: fileNamePath) - metadataForUpload.creationDate = asset.creationDate as? NSDate ?? (Date() as NSDate) - metadataForUpload.date = asset.modificationDate as? NSDate ?? (Date() as NSDate) - } catch { } + metadata.isExtractFile = true + metadata.size = utilityFileSystem.getFileSize(filePath: fileNamePath) + metadata.creationDate = asset.creationDate as? NSDate ?? (Date() as NSDate) + metadata.date = asset.modificationDate as? NSDate ?? (Date() as NSDate) + } catch {} + } + + if let result = database.getMetadataConflict( + account: session.account, + serverUrl: serverUrl, + fileNameView: fileName, + nativeFormat: metadata.nativeFormat + ) { + metadata.fileName = result.fileName + metadatasUploadInConflict.append(metadata) + } else { + metadatasNOConflict.append(metadata) + } + } + + for item in previewStore where item.tempURL != nil { + + guard let url = item.tempURL else { continue } + + let fileName = item.fileName.isEmpty + ? url.lastPathComponent + : item.fileName + + let ocId = UUID().uuidString + + let metadata = await NCManageDatabaseCreateMetadata().createMetadataAsync( + fileName: fileName, + ocId: ocId, + serverUrl: serverUrl, + session: session, + sceneIdentifier: controller?.sceneIdentifier + ) + + let toPath = utilityFileSystem.getDirectoryProviderStorageOcId( + ocId, + fileName: fileName, + userId: metadata.userId, + urlBase: metadata.urlBase + ) + + do { + let destinationURL = URL(fileURLWithPath: toPath) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + + try FileManager.default.copyItem(at: url, to: destinationURL) + + metadata.size = utilityFileSystem.getFileSize(filePath: toPath) + metadata.session = NCNetworking.shared.sessionUploadBackground + metadata.sessionSelector = global.selectorUploadFile + metadata.status = global.metadataStatusWaitUpload + metadata.sessionDate = Date() + + } catch { + print("Copy error:", error) + continue } - if let result = database.getMetadataConflict(account: session.account, serverUrl: serverUrl, fileNameView: fileName, nativeFormat: metadataForUpload.nativeFormat) { - metadataForUpload.fileName = result.fileName - metadatasUploadInConflict.append(metadataForUpload) + if let result = database.getMetadataConflict( + account: session.account, + serverUrl: serverUrl, + fileNameView: fileName, + nativeFormat: metadata.nativeFormat + ) { + metadata.fileName = result.fileName + metadatasUploadInConflict.append(metadata) } else { - metadatasNOConflict.append(metadataForUpload) + metadatasNOConflict.append(metadata) } } diff --git a/iOSClient/Main/Create/Upload Assets/NCUploadAssetsView.swift b/iOSClient/Main/Create/Upload Assets/NCUploadAssetsView.swift index 49b0445973..edc823fb4a 100644 --- a/iOSClient/Main/Create/Upload Assets/NCUploadAssetsView.swift +++ b/iOSClient/Main/Create/Upload Assets/NCUploadAssetsView.swift @@ -1,10 +1,7 @@ -// -// NCUploadAssetsView.swift -// Nextcloud -// -// Created by Marino Faggiana on 03/06/24. -// Copyright © 2024 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2024 Marino Faggiana +// SPDX-FileCopyrightText: 2026 Rasmus Wøldike +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI import NextcloudKit @@ -46,7 +43,7 @@ struct NCUploadAssetsView: View { }) { Label(NSLocalizedString("_rename_", comment: ""), systemImage: "pencil") } - if item.asset.type == .photo || item.asset.type == .livePhoto { + if item.asset?.type == .photo || item.asset?.type == .livePhoto { Button(action: { if model.presentedQuickLook(index: index, fileNamePath: fileNamePath) { self.index = index @@ -58,22 +55,22 @@ struct NCUploadAssetsView: View { } if item.data != nil { Button(action: { - if let image = model.previewStore[index].asset.fullResolutionImage?.resizeImage(size: CGSize(width: 240, height: 240), isAspectRation: true) { + if let image = model.previewStore[index].asset?.fullResolutionImage?.resizeImage(size: CGSize(width: 240, height: 240), isAspectRation: true) { model.previewStore[index].image = image model.previewStore[index].data = nil - model.previewStore[index].assetType = model.previewStore[index].asset.type + model.previewStore[index].assetType = model.previewStore[index].asset?.type ?? .photo } }) { Label(NSLocalizedString("_undo_modify_", comment: ""), systemImage: "arrow.uturn.backward.circle") } } - if item.data == nil && item.asset.type == .livePhoto && item.assetType == .livePhoto { + if item.data == nil && item.asset?.type == .livePhoto && item.assetType == .livePhoto { Button(action: { model.previewStore[index].assetType = .photo }) { Label(NSLocalizedString("_disable_livephoto_", comment: ""), systemImage: "livephoto.slash") } - } else if item.data == nil && item.asset.type == .livePhoto && item.assetType == .photo { + } else if item.data == nil && item.asset?.type == .livePhoto && item.assetType == .photo { Button(action: { model.previewStore[index].assetType = .livePhoto }) { @@ -135,9 +132,15 @@ struct NCUploadAssetsView: View { } } + if !model.tempAssets.isEmpty { + Section { + Toggle(NSLocalizedString("_save_to_camera_roll_", comment: ""), isOn: $model.saveToCameraRoll) + .font(.body) + .tint(Color(NCBrandColor.shared.getElement(account: model.session.account))) + } + } + Section { - // Auto upload requires creating folders and subfolders which are difficult to manage offline - // if NCNetworking.shared.isOnline { Toggle(isOn: $model.useAutoUploadFolder, label: { Text(NSLocalizedString("_use_folder_auto_upload_", comment: "")) @@ -260,12 +263,12 @@ struct NCUploadAssetsView: View { .frame(width: 80, height: 80, alignment: .center) .cornerRadius(10) } else { - Color(.lightGray) // Placeholder + Color(.lightGray) .frame(width: 80, height: 80) .cornerRadius(10) .onAppear { DispatchQueue.main.async { - if let asset = item.asset.phAsset, + if let asset = item.asset?.phAsset, let image = model.lowResolutionImage(asset: asset) { model.previewStore[index].image = image } @@ -295,7 +298,3 @@ struct NCUploadAssetsView: View { } } } - -#Preview { - NCUploadAssetsView(model: NCUploadAssetsModel(assets: [], serverUrl: "/", controller: nil)) -} diff --git a/iOSClient/Main/NCPickerViewController.swift b/iOSClient/Main/NCPickerViewController.swift index eb05a89255..b12b0496f2 100644 --- a/iOSClient/Main/NCPickerViewController.swift +++ b/iOSClient/Main/NCPickerViewController.swift @@ -1,13 +1,15 @@ // SPDX-FileCopyrightText: Nextcloud GmbH // SPDX-FileCopyrightText: 2018 Marino Faggiana +// SPDX-FileCopyrightText: 2026 Rasmus Wøldike // SPDX-License-Identifier: GPL-3.0-or-later import UIKit +import AVFoundation +import SwiftUI import TLPhotoPicker import MobileCoreServices import Photos import NextcloudKit -import SwiftUI // MARK: - Photo Picker @@ -43,7 +45,7 @@ class NCPhotosPickerViewController: NSObject { private func openPhotosPickerViewController(completition: @escaping ([TLPHAsset]) -> Void) { var configure = TLPhotosPickerConfigure() - var pickerVC: TLPhotosPickerViewController? + var pickerVC: customPhotoPickerViewController? configure.cancelTitle = NSLocalizedString("_cancel_", comment: "") configure.doneTitle = NSLocalizedString("_add_", comment: "") @@ -62,6 +64,10 @@ class NCPhotosPickerViewController: NSObject { completition(assets) } }, didCancel: nil) + pickerVC?.ncController = controller + + configure.usedCameraButton = true + pickerVC?.configure = configure pickerVC?.didExceedMaximumNumberOfSelection = { _ in Task { @@ -93,176 +99,791 @@ class NCPhotosPickerViewController: NSObject { } class customPhotoPickerViewController: TLPhotosPickerViewController { + + var ncController: NCMainTabBarController? + override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent } - override func makeUI() { - super.makeUI() + // MARK: - Lifecycle + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + applyCustomButtons() + } - self.customNavItem.leftBarButtonItem?.tintColor = NCBrandColor.shared.iconImageColor - self.customNavItem.rightBarButtonItem?.tintColor = NCBrandColor.shared.iconImageColor - if #available(iOS 26.0, *) { - doneButton.image = UIImage(systemName: "checkmark") - cancelButton.image = UIImage(systemName: "xmark") - navigationBarTopConstraint.constant = self.navigationBarTopConstraint.constant + 10 + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + applyCustomButtons() + } + + private func applyCustomButtons() { + guard let navItem = self.customNavItem else { return } + + if navItem.leftBarButtonItems?.contains(where: { $0.action == #selector(customAction) }) == true { + return + } + + let closeBtn = UIBarButtonItem( + barButtonSystemItem: .stop, + target: self, + action: #selector(customAction) + ) + closeBtn.tintColor = NCBrandColor.shared.iconImageColor + + var leftItems: [UIBarButtonItem] = [closeBtn] + + if PHPhotoLibrary.authorizationStatus() == .limited { + let selectPhotosBtn = UIBarButtonItem( + image: UIImage(systemName: "photo.badge.plus"), + style: .plain, + target: self, + action: #selector(selectLimitedPhotos) + ) + selectPhotosBtn.tintColor = NCBrandColor.shared.iconImageColor + leftItems.append(selectPhotosBtn) } + + navItem.leftBarButtonItems = leftItems } -} -// MARK: - Document Picker + // MARK: - Actions -class NCDocumentPickerViewController: NSObject, UIDocumentPickerDelegate { - let appDelegate = (UIApplication.shared.delegate as? AppDelegate)! - let utilityFileSystem = NCUtilityFileSystem() - let database = NCManageDatabase.shared - var isViewerMedia: Bool - var viewController: UIViewController? - var controller: NCMainTabBarController + @objc private func selectLimitedPhotos() { + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: self) + } - @discardableResult - init (controller: NCMainTabBarController, isViewerMedia: Bool, allowsMultipleSelection: Bool, viewController: UIViewController? = nil) { + override func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)? = nil) { + if viewControllerToPresent is UIImagePickerController { + openMyCustomCamera() + } else { + super.present(viewControllerToPresent, animated: animated, completion: completion) + } + } + + @objc private func openMyCustomCamera() { + guard let tabBar = ncController else { return } + let cameraVC = NCPhotosPickerCameraViewController(controller: tabBar) + cameraVC.modalPresentationStyle = .fullScreen + self.present(cameraVC, animated: true) + } + + @objc private func customAction() { + self.dismiss(animated: true) + } +} + + +@MainActor +class NCPhotosPickerCameraViewController: UIViewController, + AVCapturePhotoCaptureDelegate, + AVCaptureFileOutputRecordingDelegate { + + private var session: AVCaptureSession! + private var photoOutput: AVCapturePhotoOutput! + private var movieOutput: AVCaptureMovieFileOutput! + private var previewLayer: AVCaptureVideoPreviewLayer! + private var currentCameraPosition: AVCaptureDevice.Position = .back + private var isVideoMode = false + private var isFlashOn = false + private var recordingSeconds = 0 + private var recordingTimer: Timer? + + private weak var flashButton: UIButton? + private weak var shutterInner: UIView? + private weak var timerLabel: UILabel? + private weak var recDot: UIView? + private weak var photoModeBtn: UIButton? + private weak var videoModeBtn: UIButton? + private weak var modeSelectorStack: UIStackView? + + private weak var reviewOverlay: UIView? + private weak var reviewImageView: UIImageView? + private weak var useButton: UIButton? + private weak var playButton: UIButton? + private var capturedURL: URL? + private var capturedIsVideo = false + private var player: AVPlayer? + private var playerLayer: AVPlayerLayer? + + private weak var zoomLabel: UILabel? + private var lastZoomFactor: CGFloat = 1.0 + private var wideAngleZoomFactor: CGFloat = 1.0 + private var zoomHideTimer: Timer? + + var controller: NCMainTabBarController! + + // MARK: - Init + + init(controller: NCMainTabBarController) { self.controller = controller - self.isViewerMedia = isViewerMedia - self.viewController = viewController - super.init() + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } - let documentProviderMenu = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.data]) - - documentProviderMenu.modalPresentationStyle = .formSheet - documentProviderMenu.allowsMultipleSelection = allowsMultipleSelection - documentProviderMenu.popoverPresentationController?.sourceView = controller.tabBar - documentProviderMenu.popoverPresentationController?.sourceRect = controller.tabBar.bounds - documentProviderMenu.delegate = self - - controller.present(documentProviderMenu, animated: true, completion: nil) - } - - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - Task { @MainActor in - let session = NCSession.shared.getSession(controller: self.controller) - let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) - - if isViewerMedia, - let urlIn = urls.first, - let url = self.copySecurityScopedResource(url: urlIn, urlOut: FileManager.default.temporaryDirectory.appendingPathComponent(urlIn.lastPathComponent)), - let viewController = self.viewController { - let ocId = NSUUID().uuidString - let fileName = url.lastPathComponent - let metadata = await NCManageDatabaseCreateMetadata().createMetadataAsync( - fileName: fileName, - ocId: ocId, - serverUrl: "", - url: url.path, - session: session, - sceneIdentifier: self.controller.sceneIdentifier) - - if metadata.classFile == NKTypeClassFile.unknow.rawValue { - metadata.classFile = NKTypeClassFile.video.rawValue - } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } - if let fileNameError = FileNameValidator.checkFileName(metadata.fileNameView, account: self.controller.account, capabilities: capabilities) { - let message = "\(fileNameError.errorDescription) \(NSLocalizedString("_please_rename_file_", comment: ""))" - await UIAlertController.warningAsync( message: message, presenter: self.controller) - } else { - if let metadata = await database.addAndReturnMetadataAsync(metadata), - let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { - viewController.navigationController?.pushViewController(vc, animated: true) - } - } - } else { - let serverUrl = self.controller.currentServerUrl() - var metadatas = [tableMetadata]() - var metadatasInConflict = [tableMetadata]() - var invalidNameIndexes: [Int] = [] - - for urlIn in urls { - let ocId = NSUUID().uuidString - let fileName = urlIn.lastPathComponent - let newFileName = FileAutoRenamer.rename(fileName, capabilities: capabilities) - let toPath = utilityFileSystem.getDirectoryProviderStorageOcId(ocId, - fileName: newFileName, - userId: session.userId, - urlBase: session.urlBase) - let urlOut = URL(fileURLWithPath: toPath) - guard self.copySecurityScopedResource(url: urlIn, urlOut: urlOut) != nil else { - continue - } - let metadataForUpload = await NCManageDatabaseCreateMetadata().createMetadataAsync( - fileName: newFileName, - ocId: ocId, - serverUrl: serverUrl, - url: "", - session: session, - sceneIdentifier: self.controller.sceneIdentifier) - - metadataForUpload.session = NCNetworking.shared.sessionUploadBackground - metadataForUpload.sessionSelector = NCGlobal.shared.selectorUploadFile - metadataForUpload.size = utilityFileSystem.getFileSize(filePath: toPath) - metadataForUpload.status = NCGlobal.shared.metadataStatusWaitUpload - metadataForUpload.sessionDate = Date() - - if database.getMetadataConflict(account: session.account, serverUrl: serverUrl, fileNameView: fileName, nativeFormat: metadataForUpload.nativeFormat) != nil { - metadatasInConflict.append(metadataForUpload) - } else { - metadatas.append(metadataForUpload) - } - } + // MARK: - Lifecycle - for (index, metadata) in metadatas.enumerated() { - if let fileNameError = FileNameValidator.checkFileName(metadata.fileName, account: session.account, capabilities: capabilities) { - if metadatas.count == 1 { + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + setupCamera() + setupUI() + setupZoom() + NotificationCenter.default.addObserver(self, selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, object: nil) + } - let newFileName = await UIAlertController.renameFileAsync(fileName: metadata.fileName, - capabilities: capabilities, - account: metadata.account, - presenter: self.controller) + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } - metadatas[index].fileName = newFileName - metadatas[index].fileNameView = newFileName - metadatas[index].serverUrlFileName = utilityFileSystem.createServerUrl(serverUrl: metadatas[index].serverUrl, fileName: newFileName) + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopPlayer() + if movieOutput?.isRecording == true { + movieOutput?.stopRecording() + stopRecordingTimer() + } + UIApplication.shared.isIdleTimerDisabled = false + session?.stopRunning() + } - await self.database.addMetadatasAsync(metadatas) + @objc private func appWillResignActive() { + if movieOutput?.isRecording == true { + toggleVideo() + } + } - return - } else { - let message = "\(fileNameError.errorDescription) \(NSLocalizedString("_please_rename_file_", comment: ""))" - await UIAlertController.warningAsync( message: message, presenter: self.controller) - invalidNameIndexes.append(index) - } - } - } + // MARK: - Camera Setup - for index in invalidNameIndexes.reversed() { - metadatas.remove(at: index) - } + private func setupCamera() { + session = AVCaptureSession() + session.sessionPreset = .high + + let camera: AVCaptureDevice? + if currentCameraPosition == .back { + camera = AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: .back) + ?? AVCaptureDevice.default(.builtInDualWideCamera, for: .video, position: .back) + ?? AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) + } else { + camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) + } + guard let camera, + let videoInput = try? AVCaptureDeviceInput(device: camera), + session.canAddInput(videoInput) else { return } + session.addInput(videoInput) + + if let switchOver = camera.virtualDeviceSwitchOverVideoZoomFactors.first.map({ CGFloat($0.doubleValue) }), switchOver > 1 { + wideAngleZoomFactor = switchOver + lastZoomFactor = switchOver + try? camera.lockForConfiguration() + camera.videoZoomFactor = switchOver + camera.unlockForConfiguration() + } else { + wideAngleZoomFactor = 1.0 + lastZoomFactor = 1.0 + } + + if let audioDevice = AVCaptureDevice.default(for: .audio), + let audioInput = try? AVCaptureDeviceInput(device: audioDevice), + session.canAddInput(audioInput) { + session.addInput(audioInput) + } - await self.database.addMetadatasAsync(metadatas) + photoOutput = AVCapturePhotoOutput() + if session.canAddOutput(photoOutput) { session.addOutput(photoOutput) } - if !metadatasInConflict.isEmpty { - if let conflict = UIStoryboard(name: "NCCreateFormUploadConflict", bundle: nil).instantiateInitialViewController() as? NCCreateFormUploadConflict { - conflict.account = self.controller.account - conflict.delegate = appDelegate - conflict.serverUrl = serverUrl - conflict.metadatasUploadInConflict = metadatasInConflict + movieOutput = AVCaptureMovieFileOutput() + if session.canAddOutput(movieOutput) { session.addOutput(movieOutput) } - self.controller.present(conflict, animated: true, completion: nil) - } - } + previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + previewLayer.frame = view.bounds + view.layer.insertSublayer(previewLayer, at: 0) + + DispatchQueue.global(qos: .userInitiated).async { + self.session.startRunning() + } + } + + // MARK: - UI + + private func setupUI() { + let closeBtn = UIButton(type: .system) + closeBtn.setImage(UIImage(systemName: "xmark"), for: .normal) + closeBtn.tintColor = .white + closeBtn.translatesAutoresizingMaskIntoConstraints = false + closeBtn.addTarget(self, action: #selector(closeCamera), for: .touchUpInside) + view.addSubview(closeBtn) + + let flashBtn = UIButton(type: .system) + flashBtn.setImage(UIImage(systemName: "bolt.slash.fill"), for: .normal) + flashBtn.tintColor = .white + flashBtn.translatesAutoresizingMaskIntoConstraints = false + flashBtn.addTarget(self, action: #selector(toggleFlash), for: .touchUpInside) + view.addSubview(flashBtn) + self.flashButton = flashBtn + + let dot = UIView() + dot.backgroundColor = .systemRed + dot.layer.cornerRadius = 5 + dot.isHidden = true + dot.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(dot) + self.recDot = dot + + let timerLbl = UILabel() + timerLbl.text = "00:00" + timerLbl.textColor = .white + timerLbl.font = .monospacedDigitSystemFont(ofSize: 14, weight: .semibold) + timerLbl.isHidden = true + timerLbl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(timerLbl) + self.timerLabel = timerLbl + + let bottomArea = UIView() + bottomArea.backgroundColor = .black + bottomArea.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bottomArea) + + let photoBtn = UIButton(type: .system) + photoBtn.setTitle("FOTO", for: .normal) + photoBtn.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold) + photoBtn.setTitleColor(.white, for: .normal) + photoBtn.translatesAutoresizingMaskIntoConstraints = false + photoBtn.addTarget(self, action: #selector(setPhotoMode), for: .touchUpInside) + self.photoModeBtn = photoBtn + + let videoBtn = UIButton(type: .system) + videoBtn.setTitle("VIDEO", for: .normal) + videoBtn.titleLabel?.font = .systemFont(ofSize: 13, weight: .regular) + videoBtn.setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .normal) + videoBtn.translatesAutoresizingMaskIntoConstraints = false + videoBtn.addTarget(self, action: #selector(setVideoMode), for: .touchUpInside) + self.videoModeBtn = videoBtn + + let modeStack = UIStackView(arrangedSubviews: [photoBtn, videoBtn]) + modeStack.axis = .horizontal + modeStack.spacing = 24 + modeStack.translatesAutoresizingMaskIntoConstraints = false + bottomArea.addSubview(modeStack) + self.modeSelectorStack = modeStack + + let shutterRing = UIView() + shutterRing.layer.cornerRadius = 37 + shutterRing.layer.borderWidth = 3 + shutterRing.layer.borderColor = UIColor.white.cgColor + shutterRing.backgroundColor = .clear + shutterRing.isUserInteractionEnabled = false + shutterRing.translatesAutoresizingMaskIntoConstraints = false + bottomArea.addSubview(shutterRing) + + let innerFill = UIView() + innerFill.backgroundColor = .white + innerFill.layer.cornerRadius = 30 + innerFill.isUserInteractionEnabled = false + innerFill.translatesAutoresizingMaskIntoConstraints = false + bottomArea.addSubview(innerFill) + self.shutterInner = innerFill + + let shutterTap = UIButton(type: .custom) + shutterTap.backgroundColor = .clear + shutterTap.translatesAutoresizingMaskIntoConstraints = false + shutterTap.addTarget(self, action: #selector(shutterPressed), for: .touchUpInside) + bottomArea.addSubview(shutterTap) + + let flipBtn = UIButton(type: .system) + flipBtn.setImage(UIImage(systemName: "arrow.triangle.2.circlepath.camera.fill"), for: .normal) + flipBtn.tintColor = .white + flipBtn.translatesAutoresizingMaskIntoConstraints = false + flipBtn.addTarget(self, action: #selector(flipCamera), for: .touchUpInside) + bottomArea.addSubview(flipBtn) + + NSLayoutConstraint.activate([ + closeBtn.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + closeBtn.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + closeBtn.widthAnchor.constraint(equalToConstant: 44), + closeBtn.heightAnchor.constraint(equalToConstant: 44), + + flashBtn.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + flashBtn.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + flashBtn.widthAnchor.constraint(equalToConstant: 44), + flashBtn.heightAnchor.constraint(equalToConstant: 44), + + timerLbl.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 8), + timerLbl.centerYAnchor.constraint(equalTo: closeBtn.centerYAnchor), + + dot.centerYAnchor.constraint(equalTo: timerLbl.centerYAnchor), + dot.trailingAnchor.constraint(equalTo: timerLbl.leadingAnchor, constant: -5), + dot.widthAnchor.constraint(equalToConstant: 10), + dot.heightAnchor.constraint(equalToConstant: 10), + + bottomArea.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bottomArea.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bottomArea.bottomAnchor.constraint(equalTo: view.bottomAnchor), + bottomArea.heightAnchor.constraint(equalToConstant: 200), + + modeStack.centerXAnchor.constraint(equalTo: bottomArea.centerXAnchor), + modeStack.topAnchor.constraint(equalTo: bottomArea.topAnchor, constant: 16), + + shutterRing.centerXAnchor.constraint(equalTo: bottomArea.centerXAnchor), + shutterRing.topAnchor.constraint(equalTo: modeStack.bottomAnchor, constant: 16), + shutterRing.widthAnchor.constraint(equalToConstant: 74), + shutterRing.heightAnchor.constraint(equalToConstant: 74), + + innerFill.centerXAnchor.constraint(equalTo: shutterRing.centerXAnchor), + innerFill.centerYAnchor.constraint(equalTo: shutterRing.centerYAnchor), + innerFill.widthAnchor.constraint(equalToConstant: 60), + innerFill.heightAnchor.constraint(equalToConstant: 60), + + shutterTap.centerXAnchor.constraint(equalTo: shutterRing.centerXAnchor), + shutterTap.centerYAnchor.constraint(equalTo: shutterRing.centerYAnchor), + shutterTap.widthAnchor.constraint(equalToConstant: 80), + shutterTap.heightAnchor.constraint(equalToConstant: 80), + + flipBtn.centerYAnchor.constraint(equalTo: shutterRing.centerYAnchor), + flipBtn.trailingAnchor.constraint(equalTo: bottomArea.trailingAnchor, constant: -40), + flipBtn.widthAnchor.constraint(equalToConstant: 44), + flipBtn.heightAnchor.constraint(equalToConstant: 44), + ]) + + let overlay = UIView() + overlay.backgroundColor = .black + overlay.isHidden = true + overlay.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(overlay) + self.reviewOverlay = overlay + + let imgView = UIImageView() + imgView.contentMode = .scaleAspectFit + imgView.backgroundColor = .black + imgView.translatesAutoresizingMaskIntoConstraints = false + overlay.addSubview(imgView) + self.reviewImageView = imgView + + let reviewBar = UIView() + reviewBar.backgroundColor = .black + reviewBar.translatesAutoresizingMaskIntoConstraints = false + overlay.addSubview(reviewBar) + + let retakeBtn = UIButton(type: .system) + retakeBtn.setTitle(NSLocalizedString("_retake_", comment: ""), for: .normal) + retakeBtn.setTitleColor(.white, for: .normal) + retakeBtn.titleLabel?.font = .systemFont(ofSize: 18, weight: .regular) + retakeBtn.translatesAutoresizingMaskIntoConstraints = false + retakeBtn.addTarget(self, action: #selector(retakeCapture), for: .touchUpInside) + reviewBar.addSubview(retakeBtn) + + let useBtn = UIButton(type: .system) + useBtn.setTitleColor(.white, for: .normal) + useBtn.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold) + useBtn.translatesAutoresizingMaskIntoConstraints = false + useBtn.addTarget(self, action: #selector(useCapture), for: .touchUpInside) + reviewBar.addSubview(useBtn) + self.useButton = useBtn + + let playBtn = UIButton(type: .system) + playBtn.setImage(UIImage(systemName: "play.circle.fill"), for: .normal) + playBtn.tintColor = .white + playBtn.isHidden = true + playBtn.translatesAutoresizingMaskIntoConstraints = false + playBtn.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside) + reviewBar.addSubview(playBtn) + self.playButton = playBtn + + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: view.topAnchor), + overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor), + overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + reviewBar.leadingAnchor.constraint(equalTo: overlay.leadingAnchor), + reviewBar.trailingAnchor.constraint(equalTo: overlay.trailingAnchor), + reviewBar.bottomAnchor.constraint(equalTo: overlay.bottomAnchor), + reviewBar.heightAnchor.constraint(equalToConstant: 110), + + imgView.topAnchor.constraint(equalTo: overlay.topAnchor), + imgView.leadingAnchor.constraint(equalTo: overlay.leadingAnchor), + imgView.trailingAnchor.constraint(equalTo: overlay.trailingAnchor), + imgView.bottomAnchor.constraint(equalTo: reviewBar.topAnchor), + + retakeBtn.leadingAnchor.constraint(equalTo: reviewBar.leadingAnchor, constant: 30), + retakeBtn.topAnchor.constraint(equalTo: reviewBar.topAnchor, constant: 20), + + playBtn.centerXAnchor.constraint(equalTo: reviewBar.centerXAnchor), + playBtn.topAnchor.constraint(equalTo: reviewBar.topAnchor, constant: 14), + playBtn.widthAnchor.constraint(equalToConstant: 44), + playBtn.heightAnchor.constraint(equalToConstant: 44), + + useBtn.trailingAnchor.constraint(equalTo: reviewBar.trailingAnchor, constant: -30), + useBtn.topAnchor.constraint(equalTo: reviewBar.topAnchor, constant: 20), + ]) + } + + // MARK: - Mode + + @objc private func setPhotoMode() { + guard isVideoMode else { return } + isVideoMode = false + photoModeBtn?.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold) + photoModeBtn?.setTitleColor(.white, for: .normal) + videoModeBtn?.titleLabel?.font = .systemFont(ofSize: 13, weight: .regular) + videoModeBtn?.setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .normal) + UIView.animate(withDuration: 0.2) { + self.shutterInner?.backgroundColor = .white + self.shutterInner?.layer.cornerRadius = 30 + } + } + + @objc private func setVideoMode() { + guard !isVideoMode else { return } + isVideoMode = true + videoModeBtn?.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold) + videoModeBtn?.setTitleColor(.white, for: .normal) + photoModeBtn?.titleLabel?.font = .systemFont(ofSize: 13, weight: .regular) + photoModeBtn?.setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .normal) + UIView.animate(withDuration: 0.2) { + self.shutterInner?.backgroundColor = .systemRed + self.shutterInner?.layer.cornerRadius = 30 + } + } + + // MARK: - Flash + + @objc private func toggleFlash() { + isFlashOn.toggle() + flashButton?.setImage(UIImage(systemName: isFlashOn ? "bolt.fill" : "bolt.slash.fill"), for: .normal) + flashButton?.tintColor = isFlashOn ? .systemYellow : .white + } + + // MARK: - Actions + + @objc private func closeCamera() { + dismiss(animated: true) + } + + @objc private func shutterPressed() { + if isVideoMode { toggleVideo() } else { takePhoto() } + } + + @objc private func takePhoto() { + let settings = AVCapturePhotoSettings() + settings.flashMode = isFlashOn ? .on : .off + photoOutput?.capturePhoto(with: settings, delegate: self) + } + + @objc private func toggleVideo() { + guard let movieOutput else { return } + if movieOutput.isRecording { + movieOutput.stopRecording() + stopRecordingTimer() + UIApplication.shared.isIdleTimerDisabled = false + UIView.animate(withDuration: 0.2) { + self.shutterInner?.backgroundColor = .systemRed + self.shutterInner?.layer.cornerRadius = 30 + self.shutterInner?.transform = .identity + } + timerLabel?.isHidden = true + recDot?.isHidden = true + modeSelectorStack?.isHidden = false + } else { + let date = Date() + let keychain = NCPreferences() + let fileName = keychain.fileNameOriginal + ? "VID_\(keychain.incrementalNumber).mov" + : NCUtilityFileSystem().createFileName("VID.mov", fileDate: date, fileType: .video) + let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + movieOutput.startRecording(to: url, recordingDelegate: self) + startRecordingTimer() + UIApplication.shared.isIdleTimerDisabled = true + UIView.animate(withDuration: 0.2) { + self.shutterInner?.layer.cornerRadius = 6 + self.shutterInner?.transform = CGAffineTransform(scaleX: 0.55, y: 0.55) } + timerLabel?.isHidden = false + recDot?.isHidden = false + modeSelectorStack?.isHidden = true } } - func copySecurityScopedResource(url: URL, urlOut: URL) -> URL? { - try? FileManager.default.removeItem(at: urlOut) - if url.startAccessingSecurityScopedResource() { - do { - try FileManager.default.copyItem(at: url, to: urlOut) - url.stopAccessingSecurityScopedResource() - return urlOut - } catch { + // MARK: - Recording Timer + + private func startRecordingTimer() { + recordingSeconds = 0 + recordingTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + guard let self else { return } + self.recordingSeconds += 1 + let m = self.recordingSeconds / 60 + let s = self.recordingSeconds % 60 + self.timerLabel?.text = String(format: "%02d:%02d", m, s) + } + } + + private func stopRecordingTimer() { + recordingTimer?.invalidate() + recordingTimer = nil + timerLabel?.text = "00:00" + } + + // MARK: - Flip Camera + + @objc private func flipCamera() { + currentCameraPosition = currentCameraPosition == .back ? .front : .back + session.beginConfiguration() + for input in session.inputs.compactMap({ $0 as? AVCaptureDeviceInput }) where input.device.hasMediaType(.video) { + session.removeInput(input) + } + let newCamera: AVCaptureDevice? + if currentCameraPosition == .back { + newCamera = AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: .back) + ?? AVCaptureDevice.default(.builtInDualWideCamera, for: .video, position: .back) + ?? AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) + } else { + newCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) + } + guard let newCamera, + let input = try? AVCaptureDeviceInput(device: newCamera), + session.canAddInput(input) else { + session.commitConfiguration() + return + } + session.addInput(input) + session.commitConfiguration() + + if let switchOver = newCamera.virtualDeviceSwitchOverVideoZoomFactors.first.map({ CGFloat($0.doubleValue) }), switchOver > 1 { + wideAngleZoomFactor = switchOver + lastZoomFactor = switchOver + try? newCamera.lockForConfiguration() + newCamera.videoZoomFactor = switchOver + newCamera.unlockForConfiguration() + } else { + wideAngleZoomFactor = 1.0 + lastZoomFactor = 1.0 + } + } + + // MARK: - Zoom + + private func setupZoom() { + let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:))) + view.addGestureRecognizer(pinch) + + let label = UILabel() + label.textColor = .white + label.font = .systemFont(ofSize: 14, weight: .semibold) + label.textAlignment = .center + label.backgroundColor = UIColor.black.withAlphaComponent(0.5) + label.layer.cornerRadius = 12 + label.clipsToBounds = true + label.alpha = 0 + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + self.zoomLabel = label + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -210), + label.widthAnchor.constraint(equalToConstant: 60), + label.heightAnchor.constraint(equalToConstant: 28), + ]) + } + + @objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) { + guard let device = (session?.inputs.compactMap { $0 as? AVCaptureDeviceInput }.first(where: { $0.device.hasMediaType(.video) }))?.device else { return } + + switch gesture.state { + case .began: + lastZoomFactor = device.videoZoomFactor + case .changed: + let maxZoom = min(device.activeFormat.videoMaxZoomFactor, 10.0 * wideAngleZoomFactor) + let minZoom = device.minAvailableVideoZoomFactor + let newFactor = max(minZoom, min(lastZoomFactor * gesture.scale, maxZoom)) + try? device.lockForConfiguration() + device.videoZoomFactor = newFactor + device.unlockForConfiguration() + showZoomLabel(newFactor) + default: + break + } + } + + private func showZoomLabel(_ factor: CGFloat) { + let visualZoom = factor / wideAngleZoomFactor + let text: String + if abs(visualZoom - 0.5) < 0.05 { + text = "0.5×" + } else if abs(visualZoom - 1.0) < 0.06 { + text = "1×" + } else { + text = String(format: "%.1f×", visualZoom) + } + zoomLabel?.text = text + zoomHideTimer?.invalidate() + UIView.animate(withDuration: 0.1) { self.zoomLabel?.alpha = 1 } + zoomHideTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in + UIView.animate(withDuration: 0.3) { self?.zoomLabel?.alpha = 0 } + } + } + + // MARK: - Review + + private func showReview(url: URL, isVideo: Bool) { + capturedURL = url + capturedIsVideo = isVideo + stopPlayer() + + if isVideo { + let asset = AVURLAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + if let cgImage = try? generator.copyCGImage(at: .zero, actualTime: nil) { + reviewImageView?.image = UIImage(cgImage: cgImage) } + useButton?.setTitle(NSLocalizedString("_use_video_", comment: ""), for: .normal) + playButton?.isHidden = false + } else { + reviewImageView?.image = UIImage(contentsOfFile: url.path) + useButton?.setTitle(NSLocalizedString("_use_photo_", comment: ""), for: .normal) + playButton?.isHidden = true + } + + reviewOverlay?.isHidden = false + } + + @objc private func retakeCapture() { + stopPlayer() + capturedURL = nil + reviewImageView?.image = nil + reviewOverlay?.isHidden = true + } + + // MARK: - Video Playback + + @objc private func togglePlayback() { + guard let url = capturedURL else { return } + if let player { + if player.timeControlStatus == .playing { + player.pause() + playButton?.setImage(UIImage(systemName: "play.circle.fill"), for: .normal) + } else { + player.play() + playButton?.setImage(UIImage(systemName: "pause.circle.fill"), for: .normal) + } + } else { + let newPlayer = AVPlayer(url: url) + self.player = newPlayer + let layer = AVPlayerLayer(player: newPlayer) + layer.videoGravity = .resizeAspect + layer.frame = reviewImageView?.bounds ?? .zero + reviewImageView?.layer.addSublayer(layer) + self.playerLayer = layer + newPlayer.play() + playButton?.setImage(UIImage(systemName: "pause.circle.fill"), for: .normal) + NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinish), + name: .AVPlayerItemDidPlayToEndTime, object: newPlayer.currentItem) + } + } + + @objc private func playerDidFinish() { + player?.seek(to: .zero) + playButton?.setImage(UIImage(systemName: "play.circle.fill"), for: .normal) + } + + private func stopPlayer() { + player?.pause() + playerLayer?.removeFromSuperlayer() + playerLayer = nil + player = nil + playButton?.setImage(UIImage(systemName: "play.circle.fill"), for: .normal) + } + + @objc private func useCapture() { + guard let url = capturedURL else { return } + presentUploadView(url: url) + } + + // MARK: - Delegates + + func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { + guard let data = photo.fileDataRepresentation() else { return } + let date = Date() + let keychain = NCPreferences() + let fileName = keychain.fileNameOriginal + ? "IMG_\(keychain.incrementalNumber).JPG" + : NCUtilityFileSystem().createFileName("IMG.JPG", fileDate: date, fileType: .image) + let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + try? data.write(to: url) + showReview(url: url, isVideo: false) + } + + func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { + guard error == nil else { return } + showReview(url: outputFileURL, isVideo: true) + } + + // MARK: - Upload + + private func presentUploadView(url: URL) { + let model = NCUploadAssetsModel(tempAssets: [url], serverUrl: controller.currentServerUrl(), controller: controller) + let uploadView = NCUploadAssetsView(model: model) + let uploadVC = UIHostingController(rootView: uploadView) + guard let tabBar = controller else { return } + tabBar.dismiss(animated: true) { + tabBar.present(uploadVC, animated: true) } - return nil } } + + + + + + // MARK: - Document Picker + + class NCDocumentPickerViewController: NSObject, UIDocumentPickerDelegate { + + let controller: NCMainTabBarController + var viewController: UIViewController? + var isViewerMedia: Bool + + init(controller: NCMainTabBarController, isViewerMedia: Bool, allowsMultipleSelection: Bool, viewController: UIViewController? = nil) { + self.controller = controller + self.isViewerMedia = isViewerMedia + self.viewController = viewController + super.init() + + let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.data]) + documentPicker.modalPresentationStyle = .formSheet + documentPicker.allowsMultipleSelection = allowsMultipleSelection + documentPicker.delegate = self + documentPicker.popoverPresentationController?.sourceView = controller.tabBar + documentPicker.popoverPresentationController?.sourceRect = controller.tabBar.bounds + + controller.present(documentPicker, animated: true) + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + Task { @MainActor in + } + } + + func copySecurityScopedResource(url: URL, urlOut: URL) -> URL? { + try? FileManager.default.removeItem(at: urlOut) + if url.startAccessingSecurityScopedResource() { + do { + try FileManager.default.copyItem(at: url, to: urlOut) + url.stopAccessingSecurityScopedResource() + return urlOut + } catch { + url.stopAccessingSecurityScopedResource() + } + } + return nil + } + } diff --git a/iOSClient/Settings/Advanced/NCSettingsAdvancedModel.swift b/iOSClient/Settings/Advanced/NCSettingsAdvancedModel.swift index 1a862aa596..9c459b5941 100644 --- a/iOSClient/Settings/Advanced/NCSettingsAdvancedModel.swift +++ b/iOSClient/Settings/Advanced/NCSettingsAdvancedModel.swift @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Nextcloud GmbH // SPDX-FileCopyrightText: 2024 Aditya Tyagi // SPDX-FileCopyrightText: 2024 Marino Faggiana +// SPDX-FileCopyrightText: 2026 Rasmus Wøldike // SPDX-License-Identifier: GPL-3.0-or-later import Foundation @@ -20,6 +21,8 @@ class NCSettingsAdvancedModel: ObservableObject, ViewOnAppearHandling { @Published var livePhoto: Bool = false // State variable for indicating whether to remove photos from the camera roll after upload. @Published var removeFromCameraRoll: Bool = false + // State variable for saving custom camera media to camera roll. + @Published var saveCameraMediaToCameraRoll: Bool = false // State variable for app integration. @Published var appIntegration: Bool = false // State variable for enabling the crash reporter. @@ -55,6 +58,7 @@ class NCSettingsAdvancedModel: ObservableObject, ViewOnAppearHandling { mostCompatible = keychain.formatCompatibility livePhoto = keychain.livePhoto removeFromCameraRoll = keychain.removePhotoCameraRoll + saveCameraMediaToCameraRoll = keychain.saveCameraMediaToCameraRoll appIntegration = keychain.disableFilesApp crashReporter = keychain.disableCrashservice selectedLogLevel = keychain.log @@ -78,6 +82,11 @@ class NCSettingsAdvancedModel: ObservableObject, ViewOnAppearHandling { keychain.removePhotoCameraRoll = removeFromCameraRoll } + /// Updates the value of `saveCameraMediaToCameraRoll` in the keychain. + func updateSaveCameraMediaToCameraRoll() { + keychain.saveCameraMediaToCameraRoll = saveCameraMediaToCameraRoll + } + /// Updates the value of `appIntegration` in the keychain. func updateAppIntegration() { NSFileProviderManager.removeAllDomains { _ in } diff --git a/iOSClient/Settings/Advanced/NCSettingsAdvancedView.swift b/iOSClient/Settings/Advanced/NCSettingsAdvancedView.swift index e28cffc1bc..15afc5f771 100644 --- a/iOSClient/Settings/Advanced/NCSettingsAdvancedView.swift +++ b/iOSClient/Settings/Advanced/NCSettingsAdvancedView.swift @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Nextcloud GmbH // SPDX-FileCopyrightText: 2024 Aditya Tyagi // SPDX-FileCopyrightText: 2024 Marino Faggiana +// SPDX-FileCopyrightText: 2026 Rasmus Wøldike // SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI @@ -66,6 +67,19 @@ struct NCSettingsAdvancedView: View { Text(NSLocalizedString("_remove_photo_CameraRoll_desc_", comment: "")) .font(.footnote) }) + + // Save camera media to camera roll + Section(content: { + Toggle(NSLocalizedString("_save_to_camera_roll_", comment: ""), isOn: $model.saveCameraMediaToCameraRoll) + .font(.body) + .tint(Color(NCBrandColor.shared.getElement(account: model.session.account))) + .onChange(of: model.saveCameraMediaToCameraRoll) { + model.updateSaveCameraMediaToCameraRoll() + } + }, footer: { + Text(NSLocalizedString("_save_to_camera_roll_desc_", comment: "")) + .font(.footnote) + }) // Section : Files App if !NCBrandOptions.shared.disable_openin_file { Section(content: { diff --git a/iOSClient/Settings/NCPreferences.swift b/iOSClient/Settings/NCPreferences.swift index 387fb10bfd..d666024513 100644 --- a/iOSClient/Settings/NCPreferences.swift +++ b/iOSClient/Settings/NCPreferences.swift @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: Nextcloud GmbH // SPDX-FileCopyrightText: 2023 Marino Faggiana +// SPDX-FileCopyrightText: 2026 Rasmus Wøldike // SPDX-License-Identifier: GPL-3.0-or-later import Foundation @@ -203,6 +204,15 @@ final class NCPreferences: NSObject { } } + var saveCameraMediaToCameraRoll: Bool { + get { + return getBoolPreference(key: "saveCameraMediaToCameraRoll", defaultValue: false) + } + set { + setUserDefaults(newValue, forKey: "saveCameraMediaToCameraRoll") + } + } + var privacyScreenEnabled: Bool { get { if NCBrandOptions.shared.enforce_privacyScreenEnabled { @@ -445,10 +455,12 @@ final class NCPreferences: NSObject { } func isEndToEndEnabled(account: String) -> Bool { - guard let certificate = getEndToEndCertificate(account: account), !certificate.isEmpty, + guard let capabilities = NCNetworking.shared.capabilities[account], + let certificate = getEndToEndCertificate(account: account), !certificate.isEmpty, let publicKey = getEndToEndPublicKey(account: account), !publicKey.isEmpty, let privateKey = getEndToEndPrivateKey(account: account), !privateKey.isEmpty, - let passphrase = getEndToEndPassphrase(account: account), !passphrase.isEmpty else { + let passphrase = getEndToEndPassphrase(account: account), !passphrase.isEmpty, + NCGlobal.shared.e2eeCompatibleVersions.contains(capabilities.e2EEApiVersion) else { return false } return true diff --git a/iOSClient/Supporting Files/da.lproj/Localizable.strings b/iOSClient/Supporting Files/da.lproj/Localizable.strings index e60c1457c6337b73bed75dc5bb0675304e65dfcd..179736b1e5fdcb1f3352fb595484456c74740a42 100644 GIT binary patch delta 447 zcmX|7u}%U(5S^kjla>wy^*T zD`QM7u(b0Bs4R>z+87&+AK=>)$>w%$=e>C|Z|+;Mr#IvFr7r_};^I6fA0DGgK}U2# z0Tro+=u$~``We-(v5+@8OL?Pg-97i0&g9Rv)ywU&Z!S9IkWG2o2mOGK@z)8?A()!fIWoSGP%9;gH`uY#A;Ji*ju>ZEWk7a$zo= nZlF{ftda*IIM9odU#}_IogN>eO-Sk1L(~#j%zw_NWs1`;yd-T! delta 2972 zcmbVOO>Y}j6n#&2tu`M{(qv+pi5tfeDsCH6kSZY|77@f!7f7YBU_olR{z%<6Hu$5E zRmBF_ph6$wN$eu=1CY818y5V5s*%Ws4G}_Zc2QWd=z??ao9B7sObV?=p7G4P_uM<@ zo_Fv3{@010ze@eI_uGT-ci;F@H!5;wd+#H8Gp)W^N~*EtliT-i$y8$dhnD<8>Khe# zdU;j6E>>}jh=M4KI_~N?%AzIOICt^Ci=&CV7VZoBkCL2MS9bF!E{MEnsk;+d_0RpZ z%0EdaV5nBsruBuiJUL~Dbh^6{=A~S%? zRbkYJDZLp{TopI)uZm>dGF>9P9xoB?u1g-7bg_CHPd{Z4V~NtJ%dh4VsJPxL$;{Nz zl$WncZ}8sB6ygp8G(`ja#-xsC4ZLy_!RqS0ooQxdoXS&0x*ua{Q%$UTdcP!RiAyev zS%wZ^K>A=aPPYQB5F+q6#jN3W14;CNRbzH55h9K$G~o`paiNP04!j!3Z6h=h+{Beg z?l9?};e3SZVK*ZEs9PKx^H!X34sIOYLB{z1qvt|&E0ZqdvjGLT*vwLhJ!~46_d*Sf zkh17n9wsw_&Ps{yeb+g@5>uwOq|#w=pEWy8^s&ec&O>1U)Vij>lb#x7&dJuHBP1^v{Trbb^Ws4 zDb#gn#B+IweApG(UTH8V1)9-bOg56`45b+e~nieMG7p?_VGe$=5+N~^ycGlOWO4P Void) { DispatchQueue.main.async { switch PHPhotoLibrary.authorizationStatus() { - case PHAuthorizationStatus.authorized: + case PHAuthorizationStatus.authorized, PHAuthorizationStatus.limited: completion(true) - case PHAuthorizationStatus.denied, PHAuthorizationStatus.limited, PHAuthorizationStatus.restricted: + case PHAuthorizationStatus.denied, PHAuthorizationStatus.restricted: let alert = UIAlertController(title: NSLocalizedString("_error_", comment: ""), message: NSLocalizedString("_err_permission_photolibrary_", comment: ""), preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("_open_settings_", comment: ""), style: .default, handler: { _ in #if !EXTENSION diff --git a/iOSClient/Viewer/NCViewerQuickLook/NCViewerQuickLookView.swift b/iOSClient/Viewer/NCViewerQuickLook/NCViewerQuickLookView.swift index e2a3669ef7..b5a47394a4 100644 --- a/iOSClient/Viewer/NCViewerQuickLook/NCViewerQuickLookView.swift +++ b/iOSClient/Viewer/NCViewerQuickLook/NCViewerQuickLookView.swift @@ -27,7 +27,12 @@ struct NCViewerQuickLookView: UIViewControllerRepresentable { model.startTimer(navigationItem: controller.navigationItem) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if model.previewStore[index].assetType == .livePhoto && model.previewStore[index].asset.type == .livePhoto && model.previewStore[index].data == nil { + if index < model.previewStore.count, + let asset = model.previewStore[index].asset, + model.previewStore[index].assetType == .livePhoto, + asset.type == .livePhoto, + model.previewStore[index].data == nil { + Task { let windowScene = SceneManager.shared.getWindowScene(controller: self.model.controller) await showInfoBanner(windowScene: windowScene, text: "_message_disable_livephoto_")