diff --git a/iOSClient/Main/Create/Upload Assets/NCUploadAssetsModel.swift b/iOSClient/Main/Create/Upload Assets/NCUploadAssetsModel.swift index 2172b5c7dc..d10d46c82e 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 = NCBrandOptions.shared.isServerVersion(capabilities, greaterOrEqualTo: .v33) + 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 + } + + init(tempAssets: [URL], serverUrl: String, controller: NCMainTabBarController?) { + self.assets = [] + self.tempAssets = tempAssets + self.serverUrl = serverUrl + self.controller = controller + self.saveToCameraRoll = NCPreferences().saveCameraMediaToCameraRoll - self.previewStore.append(PreviewStore(id: localIdentifier, asset: asset, assetType: asset.type, uti: uti, nativeFormat: !NCPreferences().formatCompatibility, fileName: "")) + 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 = NCBrandOptions.shared.isServerVersion(capabilities, greaterOrEqualTo: .v33) - - 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() - } + 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 { - createProcessUploads() + 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..0a4389650e 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,22 @@ class NCPhotosPickerViewController: NSObject { completition(assets) } }, didCancel: nil) + pickerVC?.ncController = controller + pickerVC?.didCaptureMediaURL = { [weak controller] url in + let ext = url.pathExtension.lowercased() + let fileType: PHAssetMediaType = ["mov", "mp4", "m4v"].contains(ext) ? .video : .image + let originalName = fileType == .video ? "video.\(ext)" : "photo.\(ext)" + let newFileName = NCUtilityFileSystem().createFileName(originalName, fileDate: Date(), fileType: fileType) + let renamedURL = url.deletingLastPathComponent().appendingPathComponent(newFileName) + try? FileManager.default.moveItem(at: url, to: renamedURL) + guard let controller else { return } + let model = NCUploadAssetsModel(tempAssets: [renamedURL], serverUrl: controller.currentServerUrl(), controller: controller) + let uploadView = NCUploadAssetsView(model: model) + controller.present(UIHostingController(rootView: uploadView), animated: true) + } + + configure.usedCameraButton = true + pickerVC?.configure = configure pickerVC?.didExceedMaximumNumberOfSelection = { _ in Task { @@ -93,13 +110,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 +129,216 @@ 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 } - let documentProviderMenu = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.data]) + if navItem.leftBarButtonItems?.contains(where: { $0.action == #selector(customAction) }) == true { + return + } - documentProviderMenu.modalPresentationStyle = .formSheet - documentProviderMenu.allowsMultipleSelection = allowsMultipleSelection - documentProviderMenu.popoverPresentationController?.sourceView = controller.tabBar - documentProviderMenu.popoverPresentationController?.sourceRect = controller.tabBar.bounds - documentProviderMenu.delegate = self + 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) + } - controller.present(documentProviderMenu, animated: true, completion: nil) + navItem.leftBarButtonItems = leftItems } - 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 - } + // MARK: - Actions - 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] = [] + @objc private func selectLimitedPhotos() { + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: self) + } + + 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: - 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) + } - for urlIn in urls { + 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 77b957c141..c9d242ba66 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -73,6 +73,8 @@ "_no_albums_" = "No albums"; "_denied_album_" = "This app does not have access to \"Photos\". You can enable access in Privacy Settings."; "_denied_camera_" = "This app does not have access to the \"Camera\". You can enable access in Privacy Settings."; +"_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."; "_force_start_" = "Force the start"; "_account_does_not_exist_" = "The account %@ does not exist. Please log in again."; "_sharing_" = "Sharing"; 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_")