From d1aa61c5eaf8ea4562c8aff602f67dcc06a4e7b5 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 23 Apr 2026 10:28:51 +0200 Subject: [PATCH 01/11] clean Signed-off-by: Marino Faggiana --- iOSClient/NCBackgroundLocationUploadManager.swift | 1 - .../TOPasscodeViewController.m | 13 +++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/iOSClient/NCBackgroundLocationUploadManager.swift b/iOSClient/NCBackgroundLocationUploadManager.swift index 154fda662d..f010b147d3 100644 --- a/iOSClient/NCBackgroundLocationUploadManager.swift +++ b/iOSClient/NCBackgroundLocationUploadManager.swift @@ -113,7 +113,6 @@ class NCBackgroundLocationUploadManager: NSObject, CLLocationManagerDelegate { return } - let appDelegate = (UIApplication.shared.delegate as? AppDelegate)! let location = locations.last nkLog(tag: self.global.logTagLocation, emoji: .start, message: "Triggered by location change: \(location?.coordinate.latitude ?? 0), \(location?.coordinate.longitude ?? 0)") diff --git a/iOSClient/Utility/TOPasscodeViewController/TOPasscodeViewController.m b/iOSClient/Utility/TOPasscodeViewController/TOPasscodeViewController.m index f86666374d..acbdabfcbe 100755 --- a/iOSClient/Utility/TOPasscodeViewController/TOPasscodeViewController.m +++ b/iOSClient/Utility/TOPasscodeViewController/TOPasscodeViewController.m @@ -341,10 +341,10 @@ - (void)updateAccessoryButtonFontsForSize:(CGSize)size } CGFloat pointSize = 17.0f; - if (width < TOPasscodeViewContentSizeMedium) { + if (width < (CGFloat)TOPasscodeViewContentSizeMedium) { pointSize = 14.0f; } - else if (width < TOPasscodeViewContentSizeDefault) { + else if (width < (CGFloat)TOPasscodeViewContentSizeDefault) { pointSize = 16.0f; } @@ -361,10 +361,10 @@ - (void)verticalLayoutAccessoryButtonsForSize:(CGSize)size CGFloat width = MIN(size.width, size.height); CGFloat verticalInset = 44.0f; - if (width < TOPasscodeViewContentSizeMedium) { + if (width < (CGFloat)TOPasscodeViewContentSizeMedium) { verticalInset = 20.0f; } - else if (width < TOPasscodeViewContentSizeDefault) { + else if (width < (CGFloat)TOPasscodeViewContentSizeDefault) { verticalInset = 30.0f; } @@ -400,10 +400,10 @@ - (void)horizontalLayoutAccessoryButtonsForSize:(CGSize)size CGFloat buttonInset = self.passcodeView.keypadButtonInset; CGFloat width = MIN(size.width, size.height); CGFloat verticalInset = 35.0f; - if (width < TOPasscodeViewContentSizeMedium) { + if (width < (CGFloat)TOPasscodeViewContentSizeMedium) { verticalInset = 30.0f; } - else if (width < TOPasscodeViewContentSizeDefault) { + else if (width < (CGFloat)TOPasscodeViewContentSizeDefault) { verticalInset = 35.0f; } @@ -708,3 +708,4 @@ - (void)setContentHidden:(BOOL)hidden animated:(BOOL)animated } @end + From 30924b88c6806bbbc7ced71f7c5f1c682f8140ac Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 23 Apr 2026 10:35:12 +0200 Subject: [PATCH 02/11] clean Signed-off-by: Marino Faggiana --- iOSClient/Share/NCShareUserCell.xib | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/iOSClient/Share/NCShareUserCell.xib b/iOSClient/Share/NCShareUserCell.xib index 68fd98196a..0cc1819cb1 100755 --- a/iOSClient/Share/NCShareUserCell.xib +++ b/iOSClient/Share/NCShareUserCell.xib @@ -1,8 +1,8 @@ - + - + @@ -75,6 +75,7 @@ From 0e04a2ba4c07b7c7fac58bd13708847606951994 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 23 Apr 2026 10:38:17 +0200 Subject: [PATCH 03/11] clean Signed-off-by: Marino Faggiana --- iOSClient/Share/NCShareCommentsCell.xib | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/iOSClient/Share/NCShareCommentsCell.xib b/iOSClient/Share/NCShareCommentsCell.xib index 243aa83341..02a5c9efb6 100755 --- a/iOSClient/Share/NCShareCommentsCell.xib +++ b/iOSClient/Share/NCShareCommentsCell.xib @@ -1,9 +1,8 @@ - + - - + @@ -53,8 +52,8 @@ - From a5c51f35ad62cc61f23b38f671669dfe7c128ea7 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 23 Apr 2026 10:39:15 +0200 Subject: [PATCH 04/11] clean Signed-off-by: Marino Faggiana --- .../Viewer/NCViewerNextcloudText/NCViewerNextcloudText.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/iOSClient/Viewer/NCViewerNextcloudText/NCViewerNextcloudText.swift b/iOSClient/Viewer/NCViewerNextcloudText/NCViewerNextcloudText.swift index d392e1ca69..577f55ea76 100644 --- a/iOSClient/Viewer/NCViewerNextcloudText/NCViewerNextcloudText.swift +++ b/iOSClient/Viewer/NCViewerNextcloudText/NCViewerNextcloudText.swift @@ -65,7 +65,6 @@ class NCViewerNextcloudText: UIViewController, WKNavigationDelegate, WKScriptMes config.websiteDataStore = WKWebsiteDataStore.nonPersistent() let contentController = config.userContentController contentController.add(self, name: "DirectEditingMobileInterface") - // FIXME: ONLYOFFICE Due to the WK Shared Workers issue the editors cannot be opened on the devices with iOS 16.1. if editor == "onlyoffice" { let dropSharedWorkersScript = WKUserScript(source: "delete window.SharedWorker;", injectionTime: WKUserScriptInjectionTime.atDocumentStart, forMainFrameOnly: false) config.userContentController.addUserScript(dropSharedWorkersScript) From cda8b5bc584c501fa8af426e9ca017287147ab53 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 23 Apr 2026 10:54:12 +0200 Subject: [PATCH 05/11] fix Signed-off-by: Marino Faggiana --- iOSClient/Scan document/NCScan.swift | 100 ++++++++++++------ .../Advanced/NCShareAdvancePermission.swift | 2 - 2 files changed, 70 insertions(+), 32 deletions(-) diff --git a/iOSClient/Scan document/NCScan.swift b/iOSClient/Scan document/NCScan.swift index a8d5ca567b..cf67eafe34 100755 --- a/iOSClient/Scan document/NCScan.swift +++ b/iOSClient/Scan document/NCScan.swift @@ -51,6 +51,8 @@ class NCScan: UIViewController, NCScanCellCellDelegate { internal let utility = NCUtility() internal let database = NCManageDatabase.shared internal var filter: NCGlobal.TypeFilterScanDocument = NCPreferences().typeFilterScanDocument + private var editMenuInteraction: UIEditMenuInteraction? + @MainActor internal var session: NCSession.Session { NCSession.shared.getSession(controller: controller) @@ -66,6 +68,10 @@ class NCScan: UIViewController, NCScanCellCellDelegate { view.backgroundColor = .secondarySystemGroupedBackground navigationItem.title = NSLocalizedString("_scanned_images_", comment: "") + let interaction = UIEditMenuInteraction(delegate: self) + view.addInteraction(interaction) + self.editMenuInteraction = interaction + collectionViewSource.dragInteractionEnabled = true collectionViewSource.dragDelegate = self collectionViewSource.dropDelegate = self @@ -134,13 +140,6 @@ class NCScan: UIViewController, NCScanCellCellDelegate { override var canBecomeFirstResponder: Bool { return true } - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - if action == #selector(pasteImage(_:)) { - return true - } - return false - } - @objc func dismiss(_ notification: NSNotification) { self.dismiss(animated: true, completion: nil) } @@ -284,33 +283,23 @@ class NCScan: UIViewController, NCScanCellCellDelegate { } @objc func handleLongPressGesture(recognizer: UIGestureRecognizer) { - if recognizer.state == UIGestureRecognizer.State.began { - self.becomeFirstResponder() - let pasteboard = UIPasteboard.general - if let recognizerView = recognizer.view, let recognizerSuperView = recognizerView.superview, pasteboard.hasImages { - UIMenuController.shared.menuItems = [UIMenuItem(title: "Paste", action: #selector(pasteImage(_:)))] - UIMenuController.shared.showMenu(from: recognizerSuperView, rect: recognizerView.frame) - } - // TIP + guard recognizer.state == .began else { return } + becomeFirstResponder() + + guard let recognizerView = recognizer.view, + UIPasteboard.general.hasImages else { dismissTip() + return } - } - @objc func pasteImage(_ sender: Any?) { - let pasteboard = UIPasteboard.general - if pasteboard.hasImages { - guard let image = pasteboard.image?.fixedOrientation() else { return } - let fileName = utilityFileSystem.createFileName("scan.png", fileDate: Date(), fileType: PHAssetMediaType.image, notUseMask: true) - let fileNamePath = utilityFileSystem.createServerUrl(serverUrl: utilityFileSystem.directoryScan, fileName: fileName) - - do { - try image.pngData()?.write(to: NSURL.fileURL(withPath: fileNamePath), options: .atomic) - } catch { - return - } + let sourcePoint = recognizer.location(in: recognizerView) + let configuration = UIEditMenuConfiguration( + identifier: nil, + sourcePoint: sourcePoint + ) - loadImage() - } + editMenuInteraction?.presentEditMenu(with: configuration) + dismissTip() } func delete(with imageIndex: Int, sender: Any) { @@ -391,3 +380,54 @@ extension NCScan: NCViewerQuickLookDelegate { collectionViewDestination.reloadData() } } + +extension NCScan: UIEditMenuInteractionDelegate { + func editMenuInteraction( + _ interaction: UIEditMenuInteraction, + menuFor configuration: UIEditMenuConfiguration, + suggestedActions: [UIMenuElement] + ) -> UIMenu? { + guard UIPasteboard.general.hasImages else { return nil } + + let pasteAction = UIAction( + title: NSLocalizedString("_paste_file_", comment: ""), + image: UIImage(systemName: "doc.on.clipboard") + ) { [weak self] _ in + self?.pasteImage() + } + + return UIMenu(children: [pasteAction]) + } + + func pasteImage() { + let pasteboard = UIPasteboard.general + + guard pasteboard.hasImages, + let image = pasteboard.image?.fixedOrientation(), + let data = image.pngData() else { + return + } + + let fileName = utilityFileSystem.createFileName( + "scan.png", + fileDate: Date(), + fileType: .image, + notUseMask: true + ) + + let fileNamePath = utilityFileSystem.createServerUrl( + serverUrl: utilityFileSystem.directoryScan, + fileName: fileName + ) + + do { + try data.write( + to: URL(fileURLWithPath: fileNamePath), + options: .atomic + ) + loadImage() + } catch { + return + } + } +} diff --git a/iOSClient/Share/Advanced/NCShareAdvancePermission.swift b/iOSClient/Share/Advanced/NCShareAdvancePermission.swift index 1e458774c8..890c88a9bd 100644 --- a/iOSClient/Share/Advanced/NCShareAdvancePermission.swift +++ b/iOSClient/Share/Advanced/NCShareAdvancePermission.swift @@ -246,8 +246,6 @@ class NCShareAdvancePermission: UITableViewController, NCShareAdvanceFooterDeleg } if isNewShare { - let capabilities = await NKCapabilities.shared.getCapabilities(for: metadata.account) - if share.shareType != NKShare.ShareType.publicLink.rawValue, metadata.e2eEncrypted { if await NCNetworkingE2EE().isInUpload(account: metadata.account, serverUrl: metadata.serverUrlFileName) { From bd4dce84104730ef85679b808c4758f5e0082e1e Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 23 Apr 2026 11:00:13 +0200 Subject: [PATCH 06/11] clean Signed-off-by: Marino Faggiana --- iOSClient/Scan document/NCScan.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/iOSClient/Scan document/NCScan.swift b/iOSClient/Scan document/NCScan.swift index cf67eafe34..7605adaf6f 100755 --- a/iOSClient/Scan document/NCScan.swift +++ b/iOSClient/Scan document/NCScan.swift @@ -51,7 +51,9 @@ class NCScan: UIViewController, NCScanCellCellDelegate { internal let utility = NCUtility() internal let database = NCManageDatabase.shared internal var filter: NCGlobal.TypeFilterScanDocument = NCPreferences().typeFilterScanDocument + private var editMenuInteraction: UIEditMenuInteraction? + private var traitRegistration: UITraitChangeRegistration? @MainActor internal var session: NCSession.Session { @@ -72,6 +74,10 @@ class NCScan: UIViewController, NCScanCellCellDelegate { view.addInteraction(interaction) self.editMenuInteraction = interaction + traitRegistration = registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: Self, _) in + self.updateIcons() + } + collectionViewSource.dragInteractionEnabled = true collectionViewSource.dragDelegate = self collectionViewSource.dropDelegate = self @@ -131,9 +137,7 @@ class NCScan: UIViewController, NCScanCellCellDelegate { // MARK: - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - + private func updateIcons() { add.setImage(utility.loadImage(named: "plus", colors: [NCBrandColor.shared.iconImageColor]), for: .normal) transferDown.setImage(utility.loadImage(named: "arrow.down", colors: [NCBrandColor.shared.iconImageColor]), for: .normal) } From 34b3281c07a1a2e1262a26c2a536b96ed80eb0b9 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 23 Apr 2026 14:52:16 +0200 Subject: [PATCH 07/11] cod Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 4 + .../NCCollectionViewCommon+CellDelegate.swift | 4 + ...Common+UIEditMenuInteractionDelegate.swift | 125 ++++++++++++++++++ .../NCCollectionViewCommon.swift | 123 ++--------------- 4 files changed, 142 insertions(+), 114 deletions(-) create mode 100644 iOSClient/Main/Collection Common/NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 7435dfb980..5ee0c4dbfc 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -523,6 +523,7 @@ F769454022E9F077000A798A /* NCSharePaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = F769453F22E9F077000A798A /* NCSharePaging.swift */; }; F769454622E9F1B0000A798A /* NCShareCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F769454522E9F1B0000A798A /* NCShareCommon.swift */; }; F769454822E9F20D000A798A /* NCShareNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = F769454722E9F20D000A798A /* NCShareNetworking.swift */; }; + F76995F42F9A4AC400291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76995F32F9A4AC000291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift */; }; F769CA192966EA3C00039397 /* ComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F769CA182966EA3C00039397 /* ComponentView.swift */; }; F76B3CCE1EAE01BD00921AC9 /* NCBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76B3CCD1EAE01BD00921AC9 /* NCBrand.swift */; }; F76B3CCF1EAE01BD00921AC9 /* NCBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76B3CCD1EAE01BD00921AC9 /* NCBrand.swift */; }; @@ -1493,6 +1494,7 @@ F769453F22E9F077000A798A /* NCSharePaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCSharePaging.swift; sourceTree = ""; }; F769454522E9F1B0000A798A /* NCShareCommon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareCommon.swift; sourceTree = ""; }; F769454722E9F20D000A798A /* NCShareNetworking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareNetworking.swift; sourceTree = ""; }; + F76995F32F9A4AC000291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift"; sourceTree = ""; }; F769CA182966EA3C00039397 /* ComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComponentView.swift; sourceTree = ""; }; F76B3CCD1EAE01BD00921AC9 /* NCBrand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCBrand.swift; sourceTree = ""; }; F76B649B2ADFFAED00014640 /* NCImageCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCImageCache.swift; sourceTree = ""; }; @@ -2525,6 +2527,7 @@ children = ( F75FE06B2BB01D0D00A0EFEF /* Cell */, F70D7C3525FFBF81002B9E34 /* NCCollectionViewCommon.swift */, + F76995F32F9A4AC000291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift */, F7CAFE172F164B9200DB35A5 /* NCCollectionViewCommon+CellDelegate.swift */, F7743A132C33F13A0034F670 /* NCCollectionViewCommon+CollectionViewDataSource.swift */, F74D50342C9855A000BBBF4C /* NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift */, @@ -4761,6 +4764,7 @@ F70968A424212C4E00ED60E5 /* NCLivePhoto.swift in Sources */, F7C30DFA291BCF790017149B /* NCNetworkingE2EECreateFolder.swift in Sources */, F72CA05C2F5051DB002E2F06 /* AlertActionBannerView.swift in Sources */, + F76995F42F9A4AC400291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift in Sources */, F722133B2D40EF9D002F7438 /* NCFilesNavigationController.swift in Sources */, F7BC288026663F85004D46C5 /* NCViewCertificateDetails.swift in Sources */, F78B87E92B62550800C65ADC /* NCMediaDownloadThumbnail.swift in Sources */, diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift index f6ff165bfa..dda9b5cede 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + extension NCCollectionViewCommon: NCListCellDelegate, NCGridCellDelegate { func openContextMenu(with metadata: tableMetadata?, button: UIButton, sender: Any) { Task { diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift new file mode 100644 index 0000000000..b7bab2923d --- /dev/null +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit +import NextcloudKit +import RealmSwift +import LucidBanner + +extension NCCollectionViewCommon: UIEditMenuInteractionDelegate { + func openMenuItems(with objectId: String?, gestureRecognizer: UILongPressGestureRecognizer) { + guard gestureRecognizer.state == .began else { return } + guard !serverUrl.isEmpty else { return } + guard let editMenuInteraction else { return } + + let touchPoint = gestureRecognizer.location(in: collectionView) + + currentMenuObjectId = objectId + currentMenuPoint = touchPoint + + let configuration = UIEditMenuConfiguration(identifier: objectId as NSString?, sourcePoint: touchPoint) + editMenuInteraction.presentEditMenu(with: configuration) + } + + func editMenuInteraction(_ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, suggestedActions: [UIMenuElement]) -> UIMenu? { + var actions: [UIMenuElement] = [] + + if !UIPasteboard.general.items.isEmpty, + !(metadataFolder?.e2eEncrypted ?? false) { + let pasteAction = UIAction( + title: NSLocalizedString("_paste_file_", comment: "") + ) { [weak self] _ in + self?.pasteFilesMenu() + } + actions.append(pasteAction) + } + + return actions.isEmpty ? nil : UIMenu(children: actions) + } + + func editMenuInteraction(_ interaction: UIEditMenuInteraction, targetRectFor configuration: UIEditMenuConfiguration) -> CGRect { + CGRect(x: currentMenuPoint.x, y: currentMenuPoint.y, width: 1, height: 1) + } + + @objc func pasteFilesMenu() { + Task {@MainActor in + guard let tblAccount = await NCManageDatabase.shared.getTableAccountAsync(account: session.account) else { + return + } + let bannerResults = showHudBanner( + windowScene: windowScene, + title: "_upload_in_progress_") + + for (index, items) in UIPasteboard.general.items.enumerated() { + for item in items { + let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) + let results = NKFilePropertyResolver().resolve(inUTI: item.key, capabilities: capabilities) + guard let data = UIPasteboard.general.data(forPasteboardType: item.key, + inItemSet: IndexSet([index]))?.first + else { + continue + } + let fileName = results.name + "_" + NCPreferences().incrementalNumber + "." + results.ext + let serverUrlFileName = utilityFileSystem.createServerUrl(serverUrl: serverUrl, fileName: fileName) + let ocIdUpload = UUID().uuidString + let fileNameLocalPath = utilityFileSystem.getDirectoryProviderStorageOcId( + ocIdUpload, + fileName: fileName, + userId: tblAccount.userId, + urlBase: tblAccount.urlBase + ) + do { + try data.write(to: URL(fileURLWithPath: fileNameLocalPath)) + } catch { + continue + } + + let resultsUpload = await NCNetworking.shared.uploadFile(account: session.account, + fileNameLocalPath: fileNameLocalPath, + serverUrlFileName: serverUrlFileName) { _ in + } progressHandler: { _, _, fractionCompleted in + Task {@MainActor in + bannerResults.banner?.update( + payload: LucidBannerPayload.Update(progress: fractionCompleted), + for: bannerResults.token + ) + } + } + + if resultsUpload.error == .success, + let etag = resultsUpload.etag, + let ocId = resultsUpload.ocId { + let toPath = self.utilityFileSystem.getDirectoryProviderStorageOcId( + ocId, + fileName: fileName, + userId: tblAccount.userId, + urlBase: tblAccount.urlBase) + self.utilityFileSystem.moveFile(atPath: fileNameLocalPath, toPath: toPath) + NCManageDatabase.shared.addLocalFile( + account: session.account, + etag: etag, + ocId: ocId, + fileName: fileName) + Task { + await NCNetworking.shared.transferDispatcher.notifyAllDelegates { delegate in + delegate.transferReloadDataSource(serverUrl: self.serverUrl, requestData: true, status: nil) + } + } + } else { + Task { + await showErrorBanner(windowScene: windowScene, + text: resultsUpload.error.errorDescription, + errorCode: resultsUpload.error.errorCode) + } + } + } + } + + if let banner = bannerResults.banner { + banner.dismiss() + } + } + } + +} diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift index e2311b117b..df8ac1fbf2 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift @@ -55,6 +55,11 @@ class NCCollectionViewCommon: UIViewController, NCAccountSettingsModelDelegate, internal var tipViewAccounts: EasyTipView? internal var syncMetadatasTask: Task? + // + internal var editMenuInteraction: UIEditMenuInteraction? + internal var currentMenuObjectId: String? + internal var currentMenuPoint: CGPoint = .zero + // Search // internal var isSearchingMode: Bool = false @@ -204,6 +209,10 @@ class NCCollectionViewCommon: UIViewController, NCAccountSettingsModelDelegate, navigationItem.preferredSearchBarPlacement = .inline } + let interaction = UIEditMenuInteraction(delegate: self) + collectionView.addInteraction(interaction) + self.editMenuInteraction = interaction + // Cell collectionView.register(UINib(nibName: "NCListCell", bundle: nil), forCellWithReuseIdentifier: "listCell") collectionView.register(UINib(nibName: "NCGridCell", bundle: nil), forCellWithReuseIdentifier: "gridCell") @@ -592,120 +601,6 @@ class NCCollectionViewCommon: UIViewController, NCAccountSettingsModelDelegate, }) } - func openMenuItems(with objectId: String?, gestureRecognizer: UILongPressGestureRecognizer) { - if gestureRecognizer.state != .began { return } - - var listMenuItems: [UIMenuItem] = [] - let touchPoint = gestureRecognizer.location(in: collectionView) - - becomeFirstResponder() - - if !serverUrl.isEmpty { - listMenuItems.append(UIMenuItem(title: NSLocalizedString("_paste_file_", comment: ""), action: #selector(pasteFilesMenu(_:)))) - } - - if !listMenuItems.isEmpty { - UIMenuController.shared.menuItems = listMenuItems - UIMenuController.shared.showMenu(from: collectionView, rect: CGRect(x: touchPoint.x, y: touchPoint.y, width: 0, height: 0)) - } - } - - // MARK: - Menu Item - - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - if #selector(pasteFilesMenu(_:)) == action { - if !UIPasteboard.general.items.isEmpty, !(metadataFolder?.e2eEncrypted ?? false) { - return true - } - } else if #selector(copyMenuFile(_:)) == action { - return true - } else if #selector(moveMenuFile(_:)) == action { - return true - } - - return false - } - - @objc func pasteFilesMenu(_ sender: Any?) { - Task {@MainActor in - guard let tblAccount = await NCManageDatabase.shared.getTableAccountAsync(account: session.account) else { - return - } - let bannerResults = showHudBanner( - windowScene: windowScene, - title: "_upload_in_progress_") - - for (index, items) in UIPasteboard.general.items.enumerated() { - for item in items { - let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) - let results = NKFilePropertyResolver().resolve(inUTI: item.key, capabilities: capabilities) - guard let data = UIPasteboard.general.data(forPasteboardType: item.key, - inItemSet: IndexSet([index]))?.first - else { - continue - } - let fileName = results.name + "_" + NCPreferences().incrementalNumber + "." + results.ext - let serverUrlFileName = utilityFileSystem.createServerUrl(serverUrl: serverUrl, fileName: fileName) - let ocIdUpload = UUID().uuidString - let fileNameLocalPath = utilityFileSystem.getDirectoryProviderStorageOcId( - ocIdUpload, - fileName: fileName, - userId: tblAccount.userId, - urlBase: tblAccount.urlBase - ) - do { - try data.write(to: URL(fileURLWithPath: fileNameLocalPath)) - } catch { - continue - } - - let resultsUpload = await NCNetworking.shared.uploadFile(account: session.account, - fileNameLocalPath: fileNameLocalPath, - serverUrlFileName: serverUrlFileName) { _ in - } progressHandler: { _, _, fractionCompleted in - Task {@MainActor in - bannerResults.banner?.update( - payload: LucidBannerPayload.Update(progress: fractionCompleted), - for: bannerResults.token - ) - } - } - - if resultsUpload.error == .success, - let etag = resultsUpload.etag, - let ocId = resultsUpload.ocId { - let toPath = self.utilityFileSystem.getDirectoryProviderStorageOcId( - ocId, - fileName: fileName, - userId: tblAccount.userId, - urlBase: tblAccount.urlBase) - self.utilityFileSystem.moveFile(atPath: fileNameLocalPath, toPath: toPath) - NCManageDatabase.shared.addLocalFile( - account: session.account, - etag: etag, - ocId: ocId, - fileName: fileName) - Task { - await NCNetworking.shared.transferDispatcher.notifyAllDelegates { delegate in - delegate.transferReloadDataSource(serverUrl: self.serverUrl, requestData: true, status: nil) - } - } - } else { - Task { - await showErrorBanner(windowScene: windowScene, - text: resultsUpload.error.errorDescription, - errorCode: resultsUpload.error.errorCode) - } - } - } - } - - if let banner = bannerResults.banner { - banner.dismiss() - } - } - } - // MARK: - DataSource @MainActor From 52ad2ffbc11bf68b248c46a98240b7264524149c Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 23 Apr 2026 15:40:36 +0200 Subject: [PATCH 08/11] cod Signed-off-by: Marino Faggiana --- .../NCCollectionViewCommon+DragDrop.swift | 37 +------ ...Common+UIEditMenuInteractionDelegate.swift | 101 +++++++++++++++--- .../NCCollectionViewCommon.swift | 2 + 3 files changed, 89 insertions(+), 51 deletions(-) diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+DragDrop.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+DragDrop.swift index f54cc6c53e..ec775ba588 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+DragDrop.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+DragDrop.swift @@ -104,7 +104,7 @@ extension NCCollectionViewCommon: UICollectionViewDropDelegate { } } else { DragDropHover.shared.sourceMetadatas = metadatas - openMenu(collectionView: collectionView, location: coordinator.session.location(in: collectionView)) + openDragDropMenuItems(location: coordinator.session.location(in: collectionView)) } } } @@ -116,41 +116,6 @@ extension NCCollectionViewCommon: UICollectionViewDropDelegate { func collectionView(_ collectionView: UICollectionView, dropSessionDidEnd session: UIDropSession) { DragDropHover.shared.cleanPushDragDropHover() } - - // MARK: - - - private func openMenu(collectionView: UICollectionView, location: CGPoint) { - var listMenuItems: [UIMenuItem] = [] - - listMenuItems.append(UIMenuItem(title: NSLocalizedString("_copy_", comment: ""), action: #selector(copyMenuFile(_:)))) - listMenuItems.append(UIMenuItem(title: NSLocalizedString("_move_", comment: ""), action: #selector(moveMenuFile(_:)))) - UIMenuController.shared.menuItems = listMenuItems - UIMenuController.shared.showMenu(from: collectionView, rect: CGRect(x: location.x, y: location.y, width: 0, height: 0)) - } - - @objc func copyMenuFile(_ sender: Any?) { - guard let sourceMetadatas = DragDropHover.shared.sourceMetadatas else { return } - var destination: String = self.serverUrl - - if let destinationMetadata = DragDropHover.shared.destinationMetadata, destinationMetadata.directory { - destination = utilityFileSystem.createServerUrl(serverUrl: destinationMetadata.serverUrl, fileName: destinationMetadata.fileName) - } - Task { - await NCDragDrop().copyFile(metadatas: sourceMetadatas, destination: destination, controller: self.controller) - } - } - - @objc func moveMenuFile(_ sender: Any?) { - guard let sourceMetadatas = DragDropHover.shared.sourceMetadatas else { return } - var destination: String = self.serverUrl - - if let destinationMetadata = DragDropHover.shared.destinationMetadata, destinationMetadata.directory { - destination = utilityFileSystem.createServerUrl(serverUrl: destinationMetadata.serverUrl, fileName: destinationMetadata.fileName) - } - Task { - await NCDragDrop().moveFile(metadatas: sourceMetadatas, destination: destination, controller: self.controller) - } - } } // MARK: - Drop Interaction Delegate diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift index b7bab2923d..2733be4b87 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift @@ -8,29 +8,29 @@ import RealmSwift import LucidBanner extension NCCollectionViewCommon: UIEditMenuInteractionDelegate { - func openMenuItems(with objectId: String?, gestureRecognizer: UILongPressGestureRecognizer) { - guard gestureRecognizer.state == .began else { return } - guard !serverUrl.isEmpty else { return } - guard let editMenuInteraction else { return } - - let touchPoint = gestureRecognizer.location(in: collectionView) + func editMenuInteraction(_ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, suggestedActions: [UIMenuElement]) -> UIMenu? { + var actions: [UIMenuElement] = [] - currentMenuObjectId = objectId - currentMenuPoint = touchPoint + if configuration.identifier as? String == dragDropMenuIdentifier { + let copyAction = UIAction(title: NSLocalizedString("_copy_", comment: "")) { [weak self] _ in + self?.performCopyDragDropMenuAction() + } - let configuration = UIEditMenuConfiguration(identifier: objectId as NSString?, sourcePoint: touchPoint) - editMenuInteraction.presentEditMenu(with: configuration) - } + let moveAction = UIAction(title: NSLocalizedString("_move_", comment: "")) { [weak self] _ in + self?.performMoveDragDropMenuAction() + } - func editMenuInteraction(_ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, suggestedActions: [UIMenuElement]) -> UIMenu? { - var actions: [UIMenuElement] = [] + actions.append(copyAction) + actions.append(moveAction) + return UIMenu(children: actions) + } if !UIPasteboard.general.items.isEmpty, !(metadataFolder?.e2eEncrypted ?? false) { let pasteAction = UIAction( title: NSLocalizedString("_paste_file_", comment: "") ) { [weak self] _ in - self?.pasteFilesMenu() + self?.performPasteMenuAction() } actions.append(pasteAction) } @@ -42,7 +42,23 @@ extension NCCollectionViewCommon: UIEditMenuInteractionDelegate { CGRect(x: currentMenuPoint.x, y: currentMenuPoint.y, width: 1, height: 1) } - @objc func pasteFilesMenu() { + // MARK: Paste Menu + + func openMenuItems(with objectId: String?, gestureRecognizer: UILongPressGestureRecognizer) { + guard gestureRecognizer.state == .began else { return } + guard !serverUrl.isEmpty else { return } + guard let editMenuInteraction else { return } + + let touchPoint = gestureRecognizer.location(in: collectionView) + + currentMenuObjectId = objectId + currentMenuPoint = touchPoint + + let configuration = UIEditMenuConfiguration(identifier: objectId as NSString?, sourcePoint: touchPoint) + editMenuInteraction.presentEditMenu(with: configuration) + } + + private func performPasteMenuAction() { Task {@MainActor in guard let tblAccount = await NCManageDatabase.shared.getTableAccountAsync(account: session.account) else { return @@ -122,4 +138,59 @@ extension NCCollectionViewCommon: UIEditMenuInteractionDelegate { } } + // MARK: Drag&Drop Menu + + func openDragDropMenuItems(location: CGPoint) { + guard let editMenuInteraction else { return } + + currentMenuPoint = location + currentMenuObjectId = nil + + let configuration = UIEditMenuConfiguration( + identifier: dragDropMenuIdentifier as NSString, + sourcePoint: location + ) + + editMenuInteraction.presentEditMenu(with: configuration) + } + + private func performCopyDragDropMenuAction() { + guard let sourceMetadatas = DragDropHover.shared.sourceMetadatas else { return } + var destination: String = self.serverUrl + + if let destinationMetadata = DragDropHover.shared.destinationMetadata, destinationMetadata.directory { + destination = utilityFileSystem.createServerUrl( + serverUrl: destinationMetadata.serverUrl, + fileName: destinationMetadata.fileName + ) + } + + Task { + await NCDragDrop().copyFile( + metadatas: sourceMetadatas, + destination: destination, + controller: self.controller + ) + } + } + + private func performMoveDragDropMenuAction() { + guard let sourceMetadatas = DragDropHover.shared.sourceMetadatas else { return } + var destination: String = self.serverUrl + + if let destinationMetadata = DragDropHover.shared.destinationMetadata, destinationMetadata.directory { + destination = utilityFileSystem.createServerUrl( + serverUrl: destinationMetadata.serverUrl, + fileName: destinationMetadata.fileName + ) + } + + Task { + await NCDragDrop().moveFile( + metadatas: sourceMetadatas, + destination: destination, + controller: self.controller + ) + } + } } diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift index df8ac1fbf2..7db4928ad4 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift @@ -55,7 +55,9 @@ class NCCollectionViewCommon: UIViewController, NCAccountSettingsModelDelegate, internal var tipViewAccounts: EasyTipView? internal var syncMetadatasTask: Task? + // Edit Menu // + internal let dragDropMenuIdentifier = "dragdrop" internal var editMenuInteraction: UIEditMenuInteraction? internal var currentMenuObjectId: String? internal var currentMenuPoint: CGPoint = .zero From da42c44e5644087716e27048192e77736d92bcea Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 23 Apr 2026 16:09:49 +0200 Subject: [PATCH 09/11] NCLivePhoto Signed-off-by: Marino Faggiana --- iOSClient/Utility/NCLivePhoto.swift | 754 +++++++++++++++++++--------- 1 file changed, 513 insertions(+), 241 deletions(-) diff --git a/iOSClient/Utility/NCLivePhoto.swift b/iOSClient/Utility/NCLivePhoto.swift index 9a4a6c1408..b53706a253 100644 --- a/iOSClient/Utility/NCLivePhoto.swift +++ b/iOSClient/Utility/NCLivePhoto.swift @@ -1,460 +1,717 @@ // SPDX-FileCopyrightText: Nextcloud GmbH // SPDX-FileCopyrightText: 2018 Alexander Pagliaro -// SPDX-FileCopyrightText: 2022 Marino Faggiana +// SPDX-FileCopyrightText: 2026 Marino Faggiana // SPDX-License-Identifier: GPL-3.0-or-later import UIKit -import AVFoundation -import MobileCoreServices +@preconcurrency import AVFoundation import Photos import NextcloudKit import UniformTypeIdentifiers +import ImageIO -class NCLivePhoto { +final class NCLivePhoto { + // MARK: - Public - var livePhotoFile = "" - var livePhotoFile2 = "" - - // MARK: PUBLIC typealias LivePhotoResources = (pairedImage: URL, pairedVideo: URL) - /// Returns the paired image and video for the given PHLivePhoto + + /// Returns the paired image and video for the given PHLivePhoto. public class func extractResources(from livePhoto: PHLivePhoto, completion: @escaping (LivePhotoResources?) -> Void) { queue.async { shared.extractResources(from: livePhoto, completion: completion) } } - /// Generates a PHLivePhoto from an image and video. Also returns the paired image and video. + + /// Generates a PHLivePhoto from an image and video. + /// Also returns the paired image and video resources. public class func generate(from imageURL: URL?, videoURL: URL, progress: @escaping (CGFloat) -> Void, completion: @escaping (PHLivePhoto?, LivePhotoResources?) -> Void) { queue.async { - shared.generate(from: imageURL, videoURL: videoURL, progress: progress, completion: completion) + Task { + await shared.generateAsync(from: imageURL, videoURL: videoURL, progress: progress, completion: completion) + } } } - /// Save a Live Photo to the Photo Library by passing the paired image and video. + + /// Saves a Live Photo to the Photo Library using the paired image and video. public class func saveToLibrary(_ resources: LivePhotoResources, completion: @escaping (Bool) -> Void) { PHPhotoLibrary.shared().performChanges({ let creationRequest = PHAssetCreationRequest.forAsset() let options = PHAssetResourceCreationOptions() - creationRequest.addResource(with: PHAssetResourceType.pairedVideo, fileURL: resources.pairedVideo, options: options) - creationRequest.addResource(with: PHAssetResourceType.photo, fileURL: resources.pairedImage, options: options) + + creationRequest.addResource(with: .pairedVideo, fileURL: resources.pairedVideo, options: options) + creationRequest.addResource(with: .photo, fileURL: resources.pairedImage, options: options) }, completionHandler: { success, error in - if error != nil { - print(error as Any) + if let error { + print(error) } completion(success) }) } - // MARK: PRIVATE + // MARK: - Private + private static let shared = NCLivePhoto() private static let queue = DispatchQueue(label: "com.limit-point.LivePhotoQueue", attributes: .concurrent) - lazy private var cacheDirectory: URL? = { - if let cacheDirectoryURL = try? FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false) { - let fullDirectory = cacheDirectoryURL.appendingPathComponent("com.limit-point.LivePhoto", isDirectory: true) - if !FileManager.default.fileExists(atPath: fullDirectory.absoluteString) { - try? FileManager.default.createDirectory(at: fullDirectory, withIntermediateDirectories: true, attributes: nil) - } - return fullDirectory + + /// Minimal wrapper used to pass Objective-C / AVFoundation reference types through @Sendable closures. + private final class UnsafeSendableBox: @unchecked Sendable { + let value: Value + + init(_ value: Value) { + self.value = value + } + } + + private lazy var cacheDirectory: URL? = { + guard let cacheDirectoryURL = try? FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ) else { + return nil + } + + let fullDirectory = cacheDirectoryURL.appendingPathComponent("com.limit-point.LivePhoto", isDirectory: true) + + if !FileManager.default.fileExists(atPath: fullDirectory.path) { + try? FileManager.default.createDirectory(at: fullDirectory, withIntermediateDirectories: true, attributes: nil) } - return nil + + return fullDirectory }() deinit { clearCache() } - private func generateKeyPhoto(from videoURL: URL) -> URL? { - var percent: Float = 0.5 + /// Generates the JPEG key photo from the video still-image time if available, + /// otherwise falls back to the middle frame of the video. + private func generateKeyPhoto(from videoURL: URL) async -> URL? { let videoAsset = AVURLAsset(url: videoURL) - if let stillImageTime = videoAsset.stillImageTime() { - percent = Float(stillImageTime.value) / Float(videoAsset.duration.value) + + var percent: Float = 0.5 + + if let stillImageTime = await videoAsset.stillImageTimeAsync(), + let duration = try? await videoAsset.load(.duration), + duration.value != 0 { + percent = Float(stillImageTime.value) / Float(duration.value) } - guard let imageFrame = videoAsset.getAssetFrame(percent: percent) else { return nil } - guard let jpegData = imageFrame.jpegData(compressionQuality: 1) else { return nil } - guard let url = cacheDirectory?.appendingPathComponent(UUID().uuidString).appendingPathExtension("jpg") else { return nil } + + guard let imageFrame = await videoAsset.getAssetFrameAsync(percent: percent) else { + return nil + } + + guard let jpegData = imageFrame.jpegData(compressionQuality: 1) else { + return nil + } + + guard let url = cacheDirectory? + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") else { + return nil + } + do { - try? jpegData.write(to: url) + try jpegData.write(to: url, options: .atomic) return url + } catch { + print(error) + return nil } } + private func clearCache() { - if let cacheDirectory = cacheDirectory { - try? FileManager.default.removeItem(at: cacheDirectory) + guard let cacheDirectory else { + return } + + try? FileManager.default.removeItem(at: cacheDirectory) } - private func generate(from imageURL: URL?, videoURL: URL, progress: @escaping (CGFloat) -> Void, completion: @escaping (PHLivePhoto?, LivePhotoResources?) -> Void) { - guard let cacheDirectory = cacheDirectory else { + /// Generates the paired image and paired video and then builds a PHLivePhoto from them. + private func generateAsync(from imageURL: URL?, videoURL: URL, progress: @escaping (CGFloat) -> Void, completion: @escaping (PHLivePhoto?, LivePhotoResources?) -> Void) async { + guard let cacheDirectory else { DispatchQueue.main.async { completion(nil, nil) } return } + let assetIdentifier = UUID().uuidString - let _keyPhotoURL = imageURL ?? generateKeyPhoto(from: videoURL) - guard let keyPhotoURL = _keyPhotoURL, let pairedImageURL = addAssetID(assetIdentifier, toImage: keyPhotoURL, saveTo: cacheDirectory.appendingPathComponent(assetIdentifier).appendingPathExtension("jpg")) else { + + let keyPhotoURLSource: URL? + if let imageURL { + keyPhotoURLSource = imageURL + } else { + keyPhotoURLSource = await generateKeyPhoto(from: videoURL) + } + + guard + let keyPhotoURL = keyPhotoURLSource, + let pairedImageURL = addAssetID( + assetIdentifier, + toImage: keyPhotoURL, + saveTo: cacheDirectory.appendingPathComponent(assetIdentifier).appendingPathExtension("jpg") + ) + else { DispatchQueue.main.async { completion(nil, nil) } return } - addAssetID(assetIdentifier, toVideo: videoURL, saveTo: cacheDirectory.appendingPathComponent(assetIdentifier).appendingPathExtension("mov"), progress: progress) { _videoURL in - if let pairedVideoURL = _videoURL { - _ = PHLivePhoto.request(withResourceFileURLs: [pairedVideoURL, pairedImageURL], placeholderImage: nil, targetSize: CGSize.zero, contentMode: PHImageContentMode.aspectFit, resultHandler: { (livePhoto: PHLivePhoto?, info: [AnyHashable: Any]) -> Void in - if let isDegraded = info[PHLivePhotoInfoIsDegradedKey] as? Bool, isDegraded { - return - } - DispatchQueue.main.async { - completion(livePhoto, (pairedImageURL, pairedVideoURL)) - } - }) - } else { + + addAssetID( + assetIdentifier, + toVideo: videoURL, + saveTo: cacheDirectory.appendingPathComponent(assetIdentifier).appendingPathExtension("mov"), + progress: progress + ) { pairedVideoURL in + guard let pairedVideoURL else { DispatchQueue.main.async { completion(nil, nil) } + return + } + + _ = PHLivePhoto.request( + withResourceFileURLs: [pairedVideoURL, pairedImageURL], + placeholderImage: nil, + targetSize: .zero, + contentMode: .aspectFit + ) { livePhoto, info in + if let isDegraded = info[PHLivePhotoInfoIsDegradedKey] as? Bool, isDegraded { + return + } + + DispatchQueue.main.async { + completion(livePhoto, (pairedImageURL, pairedVideoURL)) + } } } } + /// Extracts the paired photo and paired video resources from a PHLivePhoto into the target directory. private func extractResources(from livePhoto: PHLivePhoto, to directoryURL: URL, completion: @escaping (LivePhotoResources?) -> Void) { - // Must be in primary Task - // let assetResources = PHAssetResource.assetResources(for: livePhoto) let group = DispatchGroup() + var keyPhotoURL: URL? var videoURL: URL? + for resource in assetResources { let buffer = NSMutableData() let options = PHAssetResourceRequestOptions() options.isNetworkAccessAllowed = true + group.enter() + PHAssetResourceManager.default().requestData(for: resource, options: options, dataReceivedHandler: { data in buffer.append(data) }) { error in if error == nil { if resource.type == .pairedVideo { videoURL = self.saveAssetResource(resource, to: directoryURL, resourceData: buffer as Data) - } else { + } else if resource.type == .photo { keyPhotoURL = self.saveAssetResource(resource, to: directoryURL, resourceData: buffer as Data) } } else { print(error as Any) } + group.leave() } } - group.notify(queue: DispatchQueue.main) { + + group.notify(queue: .main) { guard let pairedPhotoURL = keyPhotoURL, let pairedVideoURL = videoURL else { - return completion(nil) + completion(nil) + return } + completion((pairedPhotoURL, pairedVideoURL)) } } private func extractResources(from livePhoto: PHLivePhoto, completion: @escaping (LivePhotoResources?) -> Void) { - if let cacheDirectory = cacheDirectory { - extractResources(from: livePhoto, to: cacheDirectory, completion: completion) + guard let cacheDirectory else { + completion(nil) + return } + + extractResources(from: livePhoto, to: cacheDirectory, completion: completion) } + /// Saves a PHAssetResource to disk and returns the destination URL. private func saveAssetResource(_ resource: PHAssetResource, to directory: URL, resourceData: Data) -> URL? { - guard let ext = UTType(tag: resource.uniformTypeIdentifier, tagClass: .filenameExtension, conformingTo: nil)?.identifier else { return nil } - var fileUrl = directory.appendingPathComponent(NSUUID().uuidString) - fileUrl = fileUrl.appendingPathExtension(ext as String) + let type = UTType(resource.uniformTypeIdentifier) + let fileExtension = type?.preferredFilenameExtension ?? "dat" + + let fileURL = directory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(fileExtension) + do { - try resourceData.write(to: fileUrl, options: [Data.WritingOptions.atomic]) + try resourceData.write(to: fileURL, options: .atomic) + return fileURL } catch { - print("Could not save resource \(resource) to filepath \(String(describing: fileUrl))") + print("Could not save resource \(resource) to filepath \(fileURL)") return nil } - return fileUrl } + /// Adds the Live Photo asset identifier metadata to the JPEG image. func addAssetID(_ assetIdentifier: String, toImage imageURL: URL, saveTo destinationURL: URL) -> URL? { - guard let imageDestination = CGImageDestinationCreateWithURL(destinationURL as CFURL, UTType.jpeg.identifier as CFString, 1, nil), + guard + let imageDestination = CGImageDestinationCreateWithURL( + destinationURL as CFURL, + UTType.jpeg.identifier as CFString, + 1, + nil + ), let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, nil), - var imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [AnyHashable: Any] else { return nil } + var imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [AnyHashable: Any] + else { + return nil + } + let assetIdentifierKey = "17" let assetIdentifierInfo = [assetIdentifierKey: assetIdentifier] imageProperties[kCGImagePropertyMakerAppleDictionary] = assetIdentifierInfo + CGImageDestinationAddImageFromSource(imageDestination, imageSource, 0, imageProperties as CFDictionary) CGImageDestinationFinalize(imageDestination) + return destinationURL } - var audioReader: AVAssetReader? - var videoReader: AVAssetReader? - var assetWriter: AVAssetWriter? - + /// Adds the Live Photo asset identifier metadata to the MOV video. func addAssetID(_ assetIdentifier: String, toVideo videoURL: URL, saveTo destinationURL: URL, progress: @escaping (CGFloat) -> Void, completion: @escaping (URL?) -> Void) { + Task { + await addAssetIDAsync( + assetIdentifier, + toVideo: videoURL, + saveTo: destinationURL, + progress: progress, + completion: completion + ) + } + } - var audioWriterInput: AVAssetWriterInput? - var audioReaderOutput: AVAssetReaderOutput? + /// Rewrites the input video into a new MOV file including the Live Photo metadata. + private func addAssetIDAsync(_ assetIdentifier: String, toVideo videoURL: URL, saveTo destinationURL: URL, progress: @escaping (CGFloat) -> Void, completion: @escaping (URL?) -> Void) async { let videoAsset = AVURLAsset(url: videoURL) - let frameCount = videoAsset.countFrames(exact: false) - guard let videoTrack = videoAsset.tracks(withMediaType: .video).first else { - return completion(nil) - } + let frameCount = await videoAsset.countFramesAsync(exact: false) + do { - // Create the Asset Writer - assetWriter = try AVAssetWriter(outputURL: destinationURL, fileType: .mov) - // Create Video Reader Output - videoReader = try AVAssetReader(asset: videoAsset) - let videoReaderSettings = [kCVPixelBufferPixelFormatTypeKey as String: NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)] + let videoTracks = try await videoAsset.loadTracks(withMediaType: .video) + guard let videoTrack = videoTracks.first else { + completion(nil) + return + } + + let naturalSize = try await videoTrack.load(.naturalSize) + let preferredTransform = try await videoTrack.load(.preferredTransform) + + let assetWriter = try AVAssetWriter(outputURL: destinationURL, fileType: .mov) + let assetWriterBox = UnsafeSendableBox(assetWriter) + + let videoReader = try AVAssetReader(asset: videoAsset) + let videoReaderBox = UnsafeSendableBox(videoReader) + + let videoReaderSettings: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: NSNumber(value: kCVPixelFormatType_32BGRA as UInt32) + ] + let videoReaderOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: videoReaderSettings) - videoReader?.add(videoReaderOutput) - // Create Video Writer Input - let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: [AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: videoTrack.naturalSize.width, AVVideoHeightKey: videoTrack.naturalSize.height]) - videoWriterInput.transform = videoTrack.preferredTransform + videoReader.add(videoReaderOutput) + let videoReaderOutputBox = UnsafeSendableBox(videoReaderOutput) + + let videoWriterInput = AVAssetWriterInput( + mediaType: .video, + outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: naturalSize.width, + AVVideoHeightKey: naturalSize.height + ] + ) + videoWriterInput.transform = preferredTransform videoWriterInput.expectsMediaDataInRealTime = true - assetWriter?.add(videoWriterInput) - // Create Audio Reader Output & Writer Input - if let audioTrack = videoAsset.tracks(withMediaType: .audio).first { - do { - let _audioReader = try AVAssetReader(asset: videoAsset) - let _audioReaderOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: nil) - _audioReader.add(_audioReaderOutput) - audioReader = _audioReader - audioReaderOutput = _audioReaderOutput - let _audioWriterInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil) - _audioWriterInput.expectsMediaDataInRealTime = false - assetWriter?.add(_audioWriterInput) - audioWriterInput = _audioWriterInput - } catch { - print(error) - } + assetWriter.add(videoWriterInput) + let videoWriterInputBox = UnsafeSendableBox(videoWriterInput) + + var audioReader: AVAssetReader? + var audioReaderOutput: AVAssetReaderOutput? + var audioWriterInput: AVAssetWriterInput? + + let audioTracks = try await videoAsset.loadTracks(withMediaType: .audio) + if let audioTrack = audioTracks.first { + let localAudioReader = try AVAssetReader(asset: videoAsset) + let localAudioReaderOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: nil) + localAudioReader.add(localAudioReaderOutput) + + let localAudioWriterInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil) + localAudioWriterInput.expectsMediaDataInRealTime = false + assetWriter.add(localAudioWriterInput) + + audioReader = localAudioReader + audioReaderOutput = localAudioReaderOutput + audioWriterInput = localAudioWriterInput } - // Create necessary identifier metadata and still image time metadata + + let audioReaderBox = audioReader.map(UnsafeSendableBox.init) + let audioReaderOutputBox = audioReaderOutput.map(UnsafeSendableBox.init) + let audioWriterInputBox = audioWriterInput.map(UnsafeSendableBox.init) + let assetIdentifierMetadata = metadataForAssetID(assetIdentifier) let stillImageTimeMetadataAdapter = createMetadataAdaptorForStillImageTime() - assetWriter?.metadata = [assetIdentifierMetadata] - assetWriter?.add(stillImageTimeMetadataAdapter.assetWriterInput) - // Start the Asset Writer - assetWriter?.startWriting() - assetWriter?.startSession(atSourceTime: CMTime.zero) - // Add still image metadata - let _stillImagePercent: Float = 0.5 - stillImageTimeMetadataAdapter.append(AVTimedMetadataGroup(items: [metadataItemForStillImageTime()], timeRange: videoAsset.makeStillImageTimeRange(percent: _stillImagePercent, inFrameCount: frameCount))) - // For end of writing / progress - var writingVideoFinished = false - var writingAudioFinished = false - var currentFrameCount = 0 - func didCompleteWriting() { - guard writingAudioFinished && writingVideoFinished else { return } - assetWriter?.finishWriting { - if self.assetWriter?.status == .completed { - completion(destinationURL) - } else { - completion(nil) + + assetWriter.metadata = [assetIdentifierMetadata] + assetWriter.add(stillImageTimeMetadataAdapter.assetWriterInput) + + assetWriter.startWriting() + assetWriter.startSession(atSourceTime: .zero) + + let stillImagePercent: Float = 0.5 + let stillImageRange = await videoAsset.makeStillImageTimeRangeAsync( + percent: stillImagePercent, + inFrameCount: frameCount + ) + + stillImageTimeMetadataAdapter.append( + AVTimedMetadataGroup( + items: [metadataItemForStillImageTime()], + timeRange: stillImageRange + ) + ) + + let completionQueue = DispatchQueue(label: "com.nextcloud.livephoto.finish") + let completionQueueBox = UnsafeSendableBox(completionQueue) + + let state = LivePhotoWritingState(audioPresent: audioReader != nil) + let stateBox = UnsafeSendableBox(state) + + func completeIfNeeded() { + completionQueueBox.value.async { + guard stateBox.value.tryBeginFinishIfPossible() else { + return + } + + assetWriterBox.value.finishWriting { + let resultURL = assetWriterBox.value.status == .completed ? destinationURL : nil + completion(resultURL) } } } - // Start writing video - if videoReader?.startReading() ?? false { - videoWriterInput.requestMediaDataWhenReady(on: DispatchQueue(label: "videoWriterInputQueue")) { - while videoWriterInput.isReadyForMoreMediaData { - if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() { - currentFrameCount += 1 - let percent: CGFloat = CGFloat(currentFrameCount) / CGFloat(frameCount) - progress(percent) - if !videoWriterInput.append(sampleBuffer) { - print("Cannot write: \(String(describing: self.assetWriter?.error?.localizedDescription))") - self.videoReader?.cancelReading() + + if videoReader.startReading() { + videoWriterInput.requestMediaDataWhenReady(on: DispatchQueue(label: "com.nextcloud.livephoto.videoWriterInputQueue")) { + while videoWriterInputBox.value.isReadyForMoreMediaData { + if let sampleBuffer = videoReaderOutputBox.value.copyNextSampleBuffer() { + let currentFrameCount = stateBox.value.incrementVideoFrameCount() + + if frameCount > 0 { + let percent = CGFloat(currentFrameCount) / CGFloat(frameCount) + progress(percent) + } else { + progress(0) + } + + if !videoWriterInputBox.value.append(sampleBuffer) { + print("Cannot write video: \(assetWriterBox.value.error?.localizedDescription ?? "unknown error")") + videoReaderBox.value.cancelReading() + videoWriterInputBox.value.markAsFinished() + stateBox.value.markVideoFinished() + completeIfNeeded() + break } } else { - videoWriterInput.markAsFinished() - writingVideoFinished = true - didCompleteWriting() + videoWriterInputBox.value.markAsFinished() + stateBox.value.markVideoFinished() + completeIfNeeded() + break } } } } else { - writingVideoFinished = true - didCompleteWriting() + stateBox.value.markVideoFinished() + completeIfNeeded() } - // Start writing audio - if audioReader?.startReading() ?? false { - audioWriterInput?.requestMediaDataWhenReady(on: DispatchQueue(label: "audioWriterInputQueue")) { - while audioWriterInput?.isReadyForMoreMediaData ?? false { - guard let sampleBuffer = audioReaderOutput?.copyNextSampleBuffer() else { - audioWriterInput?.markAsFinished() - writingAudioFinished = true - didCompleteWriting() - return + + if let audioReaderBox, let audioReaderOutputBox, let audioWriterInputBox { + if audioReaderBox.value.startReading() { + audioWriterInputBox.value.requestMediaDataWhenReady(on: DispatchQueue(label: "com.nextcloud.livephoto.audioWriterInputQueue")) { + while audioWriterInputBox.value.isReadyForMoreMediaData { + guard let sampleBuffer = audioReaderOutputBox.value.copyNextSampleBuffer() else { + audioWriterInputBox.value.markAsFinished() + stateBox.value.markAudioFinished() + completeIfNeeded() + return + } + + if !audioWriterInputBox.value.append(sampleBuffer) { + print("Cannot write audio: \(assetWriterBox.value.error?.localizedDescription ?? "unknown error")") + audioReaderBox.value.cancelReading() + audioWriterInputBox.value.markAsFinished() + stateBox.value.markAudioFinished() + completeIfNeeded() + return + } } - audioWriterInput?.append(sampleBuffer) } + } else { + stateBox.value.markAudioFinished() + completeIfNeeded() } } else { - writingAudioFinished = true - didCompleteWriting() + stateBox.value.markAudioFinished() + completeIfNeeded() } + } catch { print(error) completion(nil) } } + /// Builds the QuickTime metadata item containing the Live Photo asset identifier. private func metadataForAssetID(_ assetIdentifier: String) -> AVMetadataItem { let item = AVMutableMetadataItem() let keyContentIdentifier = "com.apple.quicktime.content.identifier" let keySpaceQuickTimeMetadata = "mdta" + item.key = keyContentIdentifier as (NSCopying & NSObjectProtocol)? item.keySpace = AVMetadataKeySpace(rawValue: keySpaceQuickTimeMetadata) item.value = assetIdentifier as (NSCopying & NSObjectProtocol)? item.dataType = "com.apple.metadata.datatype.UTF-8" + return item } + /// Creates the metadata adaptor used to write the still-image-time metadata group. private func createMetadataAdaptorForStillImageTime() -> AVAssetWriterInputMetadataAdaptor { let keyStillImageTime = "com.apple.quicktime.still-image-time" let keySpaceQuickTimeMetadata = "mdta" - let spec: NSDictionary = [ + + let specification: NSDictionary = [ kCMMetadataFormatDescriptionMetadataSpecificationKey_Identifier as NSString: - "\(keySpaceQuickTimeMetadata)/\(keyStillImageTime)", + "\(keySpaceQuickTimeMetadata)/\(keyStillImageTime)", kCMMetadataFormatDescriptionMetadataSpecificationKey_DataType as NSString: - "com.apple.metadata.datatype.int8" ] - var desc: CMFormatDescription? - CMMetadataFormatDescriptionCreateWithMetadataSpecifications(allocator: kCFAllocatorDefault, metadataType: kCMMetadataFormatType_Boxed, metadataSpecifications: [spec] as CFArray, formatDescriptionOut: &desc) - let input = AVAssetWriterInput(mediaType: .metadata, - outputSettings: nil, sourceFormatHint: desc) + "com.apple.metadata.datatype.int8" + ] + + var description: CMFormatDescription? + CMMetadataFormatDescriptionCreateWithMetadataSpecifications( + allocator: kCFAllocatorDefault, + metadataType: kCMMetadataFormatType_Boxed, + metadataSpecifications: [specification] as CFArray, + formatDescriptionOut: &description + ) + + let input = AVAssetWriterInput( + mediaType: .metadata, + outputSettings: nil, + sourceFormatHint: description + ) + return AVAssetWriterInputMetadataAdaptor(assetWriterInput: input) } + /// Builds the QuickTime metadata item representing the still-image-time marker. private func metadataItemForStillImageTime() -> AVMetadataItem { let item = AVMutableMetadataItem() let keyStillImageTime = "com.apple.quicktime.still-image-time" let keySpaceQuickTimeMetadata = "mdta" + item.key = keyStillImageTime as (NSCopying & NSObjectProtocol)? item.keySpace = AVMetadataKeySpace(rawValue: keySpaceQuickTimeMetadata) item.value = 0 as (NSCopying & NSObjectProtocol)? item.dataType = "com.apple.metadata.datatype.int8" + return item } - } -fileprivate extension AVAsset { - func countFrames(exact: Bool) -> Int { - - var frameCount = 0 +// MARK: - LivePhotoWritingState - if let videoReader = try? AVAssetReader(asset: self) { +private final class LivePhotoWritingState { + private let lock = NSLock() - if let videoTrack = self.tracks(withMediaType: .video).first { + private var writingVideoFinished = false + private var writingAudioFinished = false + private var didFinishWriting = false + private var currentFrameCount = 0 - frameCount = Int(CMTimeGetSeconds(self.duration) * Float64(videoTrack.nominalFrameRate)) - - if exact { + init(audioPresent: Bool) { + writingAudioFinished = !audioPresent + } - frameCount = 0 + func incrementVideoFrameCount() -> Int { + lock.lock() + defer { lock.unlock() } - let videoReaderOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: nil) - videoReader.add(videoReaderOutput) + currentFrameCount += 1 + return currentFrameCount + } - videoReader.startReading() + func markVideoFinished() { + lock.lock() + writingVideoFinished = true + lock.unlock() + } - // count frames - while true { - let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() - if sampleBuffer == nil { - break - } - frameCount += 1 - } + func markAudioFinished() { + lock.lock() + writingAudioFinished = true + lock.unlock() + } - videoReader.cancelReading() - } + func tryBeginFinishIfPossible() -> Bool { + lock.lock() + defer { lock.unlock() } - } + guard writingVideoFinished, writingAudioFinished, !didFinishWriting else { + return false } - return frameCount + didFinishWriting = true + return true } +} + +fileprivate extension AVAsset { - func stillImageTime() -> CMTime? { + /// Returns the estimated or exact frame count for the first video track. + func countFramesAsync(exact: Bool) async -> Int { + do { + let videoTracks = try await loadTracks(withMediaType: .video) + guard let videoTrack = videoTracks.first else { + return 0 + } - var stillTime: CMTime? + let duration = try await load(.duration) + let nominalFrameRate = try await videoTrack.load(.nominalFrameRate) - if let videoReader = try? AVAssetReader(asset: self) { + var frameCount = Int(CMTimeGetSeconds(duration) * Float64(nominalFrameRate)) - if let metadataTrack = self.tracks(withMediaType: .metadata).first { + if exact { + frameCount = 0 - let videoReaderOutput = AVAssetReaderTrackOutput(track: metadataTrack, outputSettings: nil) + guard let videoReader = try? AVAssetReader(asset: self) else { + return 0 + } + let videoReaderOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: nil) videoReader.add(videoReaderOutput) videoReader.startReading() - let keyStillImageTime = "com.apple.quicktime.still-image-time" - let keySpaceQuickTimeMetadata = "mdta" - - var found = false - - while found == false { - if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() { - if CMSampleBufferGetNumSamples(sampleBuffer) != 0 { - let group = AVTimedMetadataGroup(sampleBuffer: sampleBuffer) - for item in group?.items ?? [] { - if item.key as? String == keyStillImageTime && item.keySpace!.rawValue == keySpaceQuickTimeMetadata { - stillTime = group?.timeRange.start - // print("stillImageTime = \(CMTimeGetSeconds(stillTime!))") - found = true - break - } - } - } - } else { + while true { + let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() + if sampleBuffer == nil { break } + frameCount += 1 } videoReader.cancelReading() - } - } - return stillTime + return frameCount + } catch { + print(error) + return 0 + } } - func makeStillImageTimeRange(percent: Float, inFrameCount: Int = 0) -> CMTimeRange { + /// Returns the still-image-time metadata timestamp if present. + func stillImageTimeAsync() async -> CMTime? { + do { + let metadataTracks = try await loadTracks(withMediaType: .metadata) + guard let metadataTrack = metadataTracks.first else { + return nil + } - var time = self.duration + guard let metadataReader = try? AVAssetReader(asset: self) else { + return nil + } - var frameCount = inFrameCount + let metadataReaderOutput = AVAssetReaderTrackOutput(track: metadataTrack, outputSettings: nil) + metadataReader.add(metadataReaderOutput) + metadataReader.startReading() - if frameCount == 0 { - frameCount = self.countFrames(exact: true) - } + let keyStillImageTime = "com.apple.quicktime.still-image-time" + let keySpaceQuickTimeMetadata = "mdta" - let frameDuration = Int64(Float(time.value) / Float(frameCount)) + while let sampleBuffer = metadataReaderOutput.copyNextSampleBuffer() { + guard CMSampleBufferGetNumSamples(sampleBuffer) != 0 else { + continue + } - time.value = Int64(Float(time.value) * percent) + let group = AVTimedMetadataGroup(sampleBuffer: sampleBuffer) - // print("stillImageTime = \(CMTimeGetSeconds(time))") + for item in group?.items ?? [] { + if item.key as? String == keyStillImageTime, + item.keySpace?.rawValue == keySpaceQuickTimeMetadata { + metadataReader.cancelReading() + return group?.timeRange.start + } + } + } - return CMTimeRangeMake(start: time, duration: CMTimeMake(value: frameDuration, timescale: time.timescale)) + metadataReader.cancelReading() + return nil + } catch { + print(error) + return nil + } } - func getAssetFrame(percent: Float) -> UIImage? { + /// Builds the time range used to mark the still image inside the video metadata timeline. + func makeStillImageTimeRangeAsync(percent: Float, inFrameCount: Int = 0) async -> CMTimeRange { + do { + let duration = try await load(.duration) - let imageGenerator = AVAssetImageGenerator(asset: self) - imageGenerator.appliesPreferredTrackTransform = true + var frameCount = inFrameCount + if frameCount == 0 { + frameCount = await countFramesAsync(exact: true) + } - imageGenerator.requestedTimeToleranceAfter = CMTimeMake(value: 1, timescale: 100) - imageGenerator.requestedTimeToleranceBefore = CMTimeMake(value: 1, timescale: 100) + guard frameCount > 0 else { + return CMTimeRange(start: .zero, duration: .zero) + } - var time = self.duration + var time = duration + let frameDurationValue = Int64(Float(time.value) / Float(frameCount)) + time.value = Int64(Float(time.value) * percent) - time.value = Int64(Float(time.value) * percent) + return CMTimeRange( + start: time, + duration: CMTime(value: frameDurationValue, timescale: time.timescale) + ) + } catch { + print(error) + return CMTimeRange(start: .zero, duration: .zero) + } + } + + /// Extracts a frame image from the asset at the given percentage of its duration. + func getAssetFrameAsync(percent: Float) async -> UIImage? { + let imageGenerator = AVAssetImageGenerator(asset: self) + imageGenerator.appliesPreferredTrackTransform = true + imageGenerator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 100) + imageGenerator.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 100) do { + let duration = try await load(.duration) + var time = duration + time.value = Int64(Float(time.value) * percent) + var actualTime = CMTime.zero let imageRef = try imageGenerator.copyCGImage(at: time, actualTime: &actualTime) - let img = UIImage(cgImage: imageRef) - - return img - } catch let error as NSError { + return UIImage(cgImage: imageRef) + } catch { print("Image generation failed with error \(error)") return nil } @@ -465,29 +722,44 @@ extension NCLivePhoto { func setLivePhoto(metadata1: tableMetadata, metadata2: tableMetadata) { Task { let capabilities = await NKCapabilities.shared.getCapabilities(for: metadata1.account) - guard capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersion28, - (!metadata1.livePhotoFile.isEmpty && !metadata2.livePhotoFile.isEmpty) else { + + guard capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersion28 else { return } - if metadata1.livePhotoFile.isEmpty { + if metadata1.livePhotoFile.isEmpty, !metadata2.fileName.isEmpty { let serverUrlfileNamePath = metadata1.urlBase + metadata1.path + metadata1.fileName - _ = await NextcloudKit.shared.setLivephotoAsync(serverUrlfileNamePath: serverUrlfileNamePath, livePhotoFile: metadata2.fileName, account: metadata2.account) { task in + + _ = await NextcloudKit.shared.setLivephotoAsync( + serverUrlfileNamePath: serverUrlfileNamePath, + livePhotoFile: metadata2.fileName, + account: metadata1.account + ) { task in Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: metadata2.account, - path: serverUrlfileNamePath, - name: "setLivephoto") + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( + account: metadata1.account, + path: serverUrlfileNamePath, + name: "setLivephoto" + ) await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) } } } - if metadata2.livePhotoFile.isEmpty { + + if metadata2.livePhotoFile.isEmpty, !metadata1.fileName.isEmpty { let serverUrlfileNamePath = metadata2.urlBase + metadata2.path + metadata2.fileName - _ = await NextcloudKit.shared.setLivephotoAsync(serverUrlfileNamePath: serverUrlfileNamePath, livePhotoFile: metadata1.fileName, account: metadata1.account) { task in + + _ = await NextcloudKit.shared.setLivephotoAsync( + serverUrlfileNamePath: serverUrlfileNamePath, + livePhotoFile: metadata1.fileName, + account: metadata2.account + ) { task in Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: metadata1.account, - path: serverUrlfileNamePath, - name: "setLivephoto") + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( + account: metadata2.account, + path: serverUrlfileNamePath, + name: "setLivephoto" + ) await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) } } From 17ff30bfca7267fd7b4345f7d659088940b99fd4 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 23 Apr 2026 16:14:44 +0200 Subject: [PATCH 10/11] clean Signed-off-by: Marino Faggiana --- Share/NCShareExtension.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Share/NCShareExtension.swift b/Share/NCShareExtension.swift index 6d3ed1cdbb..f3e5f8b60b 100644 --- a/Share/NCShareExtension.swift +++ b/Share/NCShareExtension.swift @@ -92,6 +92,13 @@ class NCShareExtension: UIViewController { NCBrandColor.shared.createUserColors() + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: Self, _) in + guard !self.maintenanceMode else { + return + } + self.updateAppearance() + } + NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { _ in if NCPreferences().presentPasscode { NCPasscode.shared.presentPasscode(viewController: self, delegate: self) { @@ -167,13 +174,9 @@ class NCShareExtension: UIViewController { } } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if !maintenanceMode { - collectionView.reloadData() - tableView.reloadData() - } + private func updateAppearance() { + collectionView.visibleCells.forEach { $0.setNeedsLayout() } + tableView.visibleCells.forEach { $0.setNeedsLayout() } } // MARK: - From 2ebb8e685f62b7ab645c178356d4f5c1419e0df8 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 23 Apr 2026 16:15:09 +0200 Subject: [PATCH 11/11] build 5 Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 5ee0c4dbfc..e55109735a 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -5826,7 +5826,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -5893,7 +5893,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = NKUJUXUJ3B;