diff --git a/iOSClient/Main/Create/Upload Assets/NCUploadAssetsModel.swift b/iOSClient/Main/Create/Upload Assets/NCUploadAssetsModel.swift index 04f9521bd1..29aa56204a 100644 --- a/iOSClient/Main/Create/Upload Assets/NCUploadAssetsModel.swift +++ b/iOSClient/Main/Create/Upload Assets/NCUploadAssetsModel.swift @@ -1,30 +1,82 @@ // 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: - 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() + } + } + + // Saving to camera roll is deferred until after upload confirmation + // to avoid storing media that the user cancels. + 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,23 +84,28 @@ 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 @@ -61,224 +118,286 @@ 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 } - func updateUseAutoUploadFolder() { - keychain.setUploadUseAutoUploadFolder(account: session.account, value: useAutoUploadFolder) - } + // 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 updateUseAutoUploadSubFolder() { - keychain.setUploadUseAutoUploadSubFolder(account: session.account, value: useAutoUploadSubFolder) - } + buttonCrop.isEnabled = true + buttonDone.isEnabled = true - 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 + 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 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) + } + } + + // Camera assets are stored as temporary files rather than PHAssets, + // so they must be copied directly from the temp URL to the upload destination. + 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 b7edd425aa..d062926fc4 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 @@ -45,7 +42,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 @@ -57,22 +54,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 }) { @@ -134,9 +131,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: "")) @@ -259,12 +262,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 } @@ -294,7 +297,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..796c9044b2 100644 --- a/iOSClient/Main/NCPickerViewController.swift +++ b/iOSClient/Main/NCPickerViewController.swift @@ -1,13 +1,14 @@ // 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 SwiftUI import TLPhotoPicker -import MobileCoreServices import Photos import NextcloudKit -import SwiftUI +import UniformTypeIdentifiers // MARK: - Photo Picker @@ -43,7 +44,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 +63,10 @@ class NCPhotosPickerViewController: NSObject { completition(assets) } }, didCancel: nil) + pickerVC?.ncController = controller + + configure.usedCameraButton = true + pickerVC?.configure = configure pickerVC?.didExceedMaximumNumberOfSelection = { _ in Task { @@ -93,13 +98,17 @@ class NCPhotosPickerViewController: NSObject { } class customPhotoPickerViewController: TLPhotosPickerViewController { + + var ncController: NCMainTabBarController? + override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent } + // MARK: - Lifecycle + override func makeUI() { super.makeUI() - self.customNavItem.leftBarButtonItem?.tintColor = NCBrandColor.shared.iconImageColor self.customNavItem.rightBarButtonItem?.tintColor = NCBrandColor.shared.iconImageColor if #available(iOS 26.0, *) { @@ -108,161 +117,248 @@ class customPhotoPickerViewController: TLPhotosPickerViewController { navigationBarTopConstraint.constant = self.navigationBarTopConstraint.constant + 10 } } -} -// MARK: - Document Picker + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + applyCustomButtons() + } -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 + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + applyCustomButtons() + } - @discardableResult - init (controller: NCMainTabBarController, isViewerMedia: Bool, allowsMultipleSelection: Bool, viewController: UIViewController? = nil) { - self.controller = controller - self.isViewerMedia = isViewerMedia - self.viewController = viewController - super.init() + private func applyCustomButtons() { + guard let navItem = self.customNavItem else { return } + + if navItem.leftBarButtonItems?.contains(where: { $0.action == #selector(customAction) }) == true { + return + } - let documentProviderMenu = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.data]) + 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) + } - documentProviderMenu.modalPresentationStyle = .formSheet - documentProviderMenu.allowsMultipleSelection = allowsMultipleSelection - documentProviderMenu.popoverPresentationController?.sourceView = controller.tabBar - documentProviderMenu.popoverPresentationController?.sourceRect = controller.tabBar.bounds - documentProviderMenu.delegate = self + navItem.leftBarButtonItems = leftItems + } - controller.present(documentProviderMenu, animated: true, completion: nil) + // MARK: - Actions + + @objc private func selectLimitedPhotos() { + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: self) } - 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 - } + // Intercept TLPhotosPickerViewController's camera presentation to inject ourselves + // as delegate, so we can receive the photo/video without saving to camera roll. + override func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)? = nil) { + if let imagePicker = viewControllerToPresent as? UIImagePickerController { + imagePicker.delegate = self + super.present(imagePicker, animated: animated, completion: completion) + } else { + super.present(viewControllerToPresent, animated: animated, completion: completion) + } + } - 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] = [] + private func presentUploadView(url: URL) { + guard let controller = ncController else { return } + let model = NCUploadAssetsModel(tempAssets: [url], serverUrl: controller.currentServerUrl(), controller: controller) + let uploadView = NCUploadAssetsView(model: model) + let uploadVC = UIHostingController(rootView: uploadView) + self.dismiss(animated: true) { + controller.present(uploadVC, animated: true) + } + } + + @objc private func customAction() { + self.dismiss(animated: true) + } +} + +// MARK: - UIImagePickerControllerDelegate + +extension customPhotoPickerViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + picker.dismiss(animated: true) - for urlIn in urls { + let tempDir = FileManager.default.temporaryDirectory + + if let videoURL = info[.mediaURL] as? URL { + let destURL = tempDir.appendingPathComponent(UUID().uuidString + ".mov") + try? FileManager.default.copyItem(at: videoURL, to: destURL) + presentUploadView(url: destURL) + } else if let image = info[.originalImage] as? UIImage, + let data = image.jpegData(compressionQuality: 0.9) { + let destURL = tempDir.appendingPathComponent(UUID().uuidString + ".jpg") + try? data.write(to: destURL) + presentUploadView(url: destURL) + } + } +} + + // MARK: - Document Picker + + class NCDocumentPickerViewController: NSObject, UIDocumentPickerDelegate { + + let appDelegate = (UIApplication.shared.delegate as? AppDelegate)! + let utilityFileSystem = NCUtilityFileSystem() + let database = NCManageDatabase.shared + 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 + 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 = 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, + let fileName = url.lastPathComponent + let metadata = await NCManageDatabaseCreateMetadata().createMetadataAsync( + fileName: fileName, ocId: ocId, - serverUrl: serverUrl, - url: "", + serverUrl: "", + url: url.path, 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 metadata.classFile == NKTypeClassFile.unknow.rawValue { + metadata.classFile = NKTypeClassFile.video.rawValue + } - if database.getMetadataConflict(account: session.account, serverUrl: serverUrl, fileNameView: fileName, nativeFormat: metadataForUpload.nativeFormat) != nil { - metadatasInConflict.append(metadataForUpload) + 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 { - metadatas.append(metadataForUpload) + if let metadata = await database.addAndReturnMetadataAsync(metadata), + let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + viewController.navigationController?.pushViewController(vc, animated: true) + } } - } - - for (index, metadata) in metadatas.enumerated() { - if let fileNameError = FileNameValidator.checkFileName(metadata.fileName, account: session.account, capabilities: capabilities) { - if metadatas.count == 1 { - - let newFileName = await UIAlertController.renameFileAsync(fileName: metadata.fileName, - capabilities: capabilities, - account: metadata.account, - presenter: self.controller) - - metadatas[index].fileName = newFileName - metadatas[index].fileNameView = newFileName - metadatas[index].serverUrlFileName = utilityFileSystem.createServerUrl(serverUrl: metadatas[index].serverUrl, fileName: newFileName) - - await self.database.addMetadatasAsync(metadatas) - - return + } 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 { - let message = "\(fileNameError.errorDescription) \(NSLocalizedString("_please_rename_file_", comment: ""))" - await UIAlertController.warningAsync( message: message, presenter: self.controller) - invalidNameIndexes.append(index) + metadatas.append(metadataForUpload) } } - } - for index in invalidNameIndexes.reversed() { - metadatas.remove(at: index) - } + for (index, metadata) in metadatas.enumerated() { + if let fileNameError = FileNameValidator.checkFileName(metadata.fileName, account: session.account, capabilities: capabilities) { + if metadatas.count == 1 { + let newFileName = await UIAlertController.renameFileAsync(fileName: metadata.fileName, + capabilities: capabilities, + account: metadata.account, + presenter: self.controller) + metadatas[index].fileName = newFileName + metadatas[index].fileNameView = newFileName + metadatas[index].serverUrlFileName = utilityFileSystem.createServerUrl(serverUrl: metadatas[index].serverUrl, fileName: newFileName) + await self.database.addMetadatasAsync(metadatas) + return + } else { + let message = "\(fileNameError.errorDescription) \(NSLocalizedString("_please_rename_file_", comment: ""))" + await UIAlertController.warningAsync(message: message, presenter: self.controller) + invalidNameIndexes.append(index) + } + } + } - await self.database.addMetadatasAsync(metadatas) + for index in invalidNameIndexes.reversed() { + metadatas.remove(at: index) + } - 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 + await self.database.addMetadatasAsync(metadatas) - self.controller.present(conflict, animated: true, completion: nil) + 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 + self.controller.present(conflict, animated: true, completion: nil) + } } } } } - } - 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 { + 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 } - 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..badd971c0b 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: true) + } + set { + setUserDefaults(newValue, forKey: "saveCameraMediaToCameraRoll") + } + } + var privacyScreenEnabled: Bool { get { if NCBrandOptions.shared.enforce_privacyScreenEnabled { diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 23851fc7e0..7929e33bb9 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -317,6 +317,11 @@ "_error_createsubfolders_upload_" = "Error creating subfolders"; "_remove_photo_CameraRoll_" = "Remove from camera roll"; "_remove_photo_CameraRoll_desc_" = "\"Remove from camera roll\" after uploads, a confirmation message will be displayed to delete the uploaded photos or videos from the camera roll. The deleted photos or videos will still be available in the iOS Photos Trash for 30 days."; +"_save_to_camera_roll_" = "Save to camera roll"; +"_save_to_camera_roll_desc_" = "When enabled, photos and videos taken with the in-app camera will also be saved to your iOS camera roll."; +"_retake_" = "Retake"; +"_use_photo_" = "Use Photo"; +"_use_video_" = "Use Video"; "_never_" = "never"; "_less_a_minute_" = "less than a minute ago"; "_a_minute_ago_" = "a minute ago"; @@ -731,7 +736,6 @@ You can stop it at any time, adjust the settings, and enable it again."; "_assistant_error_send_message_" = "Could not send message. Please try again."; "_assistant_error_load_messages_" = "Could not load messages. Please try again."; "_assistant_error_generate_response_" = "Could not generate response. Please try again."; - // MARK: Client certificate "_no_client_cert_found_" = "The server is requesting a client certificate."; "_no_client_cert_found_desc_" = "Do you want to install a TLS client certificate? \n Note that the .p12 certificate must be installed on your device first by clicking on it and installing it as an Identitity Certificate Profile in Settings. The certificate MUST also have a password as that is a requirement by iOS."; @@ -761,12 +765,14 @@ You can stop it at any time, adjust the settings, and enable it again."; "_albums_" = "Albums"; "_new_photos_only_" = "New photos only"; "_all_photos_" = "All photos"; +//"_back_up_" = "Back up..."; "_back_up_new_photos_only_" = "Back up new photos/videos only"; "_auto_upload_all_photos_warning_title_" = "Are you sure you want to upload all photos?"; "_auto_upload_all_photos_warning_message_" = "This can take some time to process depending on the amount of photos."; "_item_with_same_name_already_exists_" = "An item with the same name already exists."; // MARK: Migration Multi Domains + "_preparing_migration_" = "Preparing migration …"; "_scanning_files_" = "Scanning files …"; "_moving_items_to_domain_" = "Moving items to correct domain …"; diff --git a/iOSClient/Utility/NCAskAuthorization.swift b/iOSClient/Utility/NCAskAuthorization.swift index 3834fa9a28..a58f97c3d8 100644 --- a/iOSClient/Utility/NCAskAuthorization.swift +++ b/iOSClient/Utility/NCAskAuthorization.swift @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: Nextcloud GmbH // SPDX-FileCopyrightText: 2021 Marino Faggiana +// SPDX-FileCopyrightText: 2026 Rasmus Wøldike // SPDX-License-Identifier: GPL-3.0-or-later import UIKit @@ -47,9 +48,9 @@ class NCAskAuthorization: NSObject { func askAuthorizationPhotoLibrary(controller: UIViewController?, completion: @escaping (_ hasPermission: Bool) -> 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_")