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 e60c1457c6..179736b1e5 100644 Binary files a/iOSClient/Supporting Files/da.lproj/Localizable.strings and b/iOSClient/Supporting Files/da.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 33b9cc35c7..fd0556a9b3 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -312,6 +312,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"; @@ -469,6 +474,7 @@ "_e2e_remove_folder_encrypted_" = "Decrypt"; "_e2e_file_encrypted_" = "File encrypted"; "_e2e_goto_settings_for_enable_" = "This is an encrypted directory, go to \"Settings\" and enable end-to-end encryption"; +"_e2e_error_" = "An internal end-to-end encryption error occurred."; "_e2e_in_upload_" = "Upload in progress, please wait for all files to be transferred."; "_scans_document_" = "Scan document"; "_scanned_images_" = "Scanned images"; @@ -726,7 +732,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."; @@ -756,48 +761,15 @@ 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 …"; "_finishing_up_" = "Finishing up …"; - -// MARK: E2EE -"_e2ee_no_session_" = "End-to-end encryption error, no account for this session"; -"_e2ee_no_dir_" = "End-to-end encryption error, cannot get directory"; -"_e2ee_no_certificate_" = "End-to-end encryption error, cannot get certificate"; -"_e2ee_no_generate_key_" = "End-to-end encryption error, cannot generate key"; -"_e2ee_no_user_found_" = "End-to-end encryption error, user not found"; -"_e2ee_no_signature_found_" = "End-to-end encryption error, no signature found"; -"_e2ee_counter_check_" = "End-to-end encryption error, the counter is lower than the previous one"; -"_e2ee_signature_failed_" = "End-to-end encryption error, verify signature failed"; -"_e2ee_enc_payload_" = "End-to-end encryption error, encrypt payload file"; -"_e2ee_no_metadata_" = "End-to-end encryption error, cannot get metadata"; -"_e2ee_filedrop_ciphertext_" = "End-to-end encryption error, filedrop ciphertext failed"; -"_e2ee_key_ciphertext_" = "End-to-end encryption error, key ciphertext failed"; -"_e2ee_key_checksums_" = "End-to-end encryption error, key checksums failed"; -"_e2ee_decode_metadata_" = "End-to-end encryption error, cannot decode metadata"; -"_e2ee_no_lock_" = "End-to-end encryption error, unable to lock file"; -"_e2ee_no_decrypt_metadata_" = "End-to-end encryption error, cannot decrypt metadata"; -"_e2ee_no_enc_file_" = "End-to-end encryption error, cannot encrypt file"; -"_e2ee_no_enc_key_" = "End-to-end encryption error, cannot get encoded key"; -"_e2ee_no_match_checksum_" = "End-to-end encryption error, checksum does not match"; -"_e2ee_create_folder_" = "End-to-end encryption directory creation in progress, please wait …"; -"_e2ee_rename_file_" = "End-to-end encryption rename in progress, please wait …"; -"_e2ee_encrypt_folder_" = "End-to-end encryption folder in progress, please wait …"; -"_e2ee_setup_get_certificate_" = "Server did not return a certificate"; -"_e2ee_setup_create_csr_" = "Unable to generate certificate signing request (CSR)"; -"_e2ee_setup_sign_certificate_" = "Server did not return a certificate after signing request"; -"_e2ee_setup_extract_publickey_" = "Public key mismatch between generated key and certificate"; -"_e2ee_setup_get_privatekey_" = "Server did not return an encrypted private key"; -"_e2ee_setup_passphrase_error_" = "The provided passphrase is incorrect"; -"_e2ee_setup_get_publickey_" = "Server did not return an encrypted public key"; -"_e2ee_setup_encript_privatekey_" = "Unable to encrypt the private key"; -"_e2ee_setup_store_privatekey_" = "Private key generation returned no usable key"; -"_e2ee_setup_verify_publickey_" = "Public key does not match the certificate"; -"_e2ee_setup_generate_passphrase_" = "Unable to generate a passphrase"; 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_")