From d7b1ecbacb52f78977b401e694c047cbe32df151 Mon Sep 17 00:00:00 2001 From: harshada-15-tsys Date: Thu, 30 Apr 2026 18:29:29 +0530 Subject: [PATCH 1/4] NMC 3733 - Albums on mobile app customisation --- .../xcshareddata/swiftpm/Package.resolved | 375 ---------------- .../Data/NCManageDatabase+Metadata.swift | 13 + iOSClient/Main/Main.storyboard | 152 ++++++- .../Media/NCMedia+ScrollViewDelegate.swift | 19 +- iOSClient/Media/NCMedia.swift | 408 +++++++++++++++++- iOSClient/Media/NCMediaHost.swift | 115 +++++ iOSClient/NCGlobal.swift | 32 ++ iOSClient/Networking/NCNetworking.swift | 5 + 8 files changed, 724 insertions(+), 395 deletions(-) delete mode 100644 Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 iOSClient/Media/NCMediaHost.swift diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 368a41312f..0000000000 --- a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,375 +0,0 @@ -{ - "originHash" : "7880536aedfe5f5b326df402ac666a6126ac1b325106ab3eb7b6c78a6ef725ff", - "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" - } - }, - { - "identity" : "alamofire", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Alamofire/Alamofire", - "state" : { - "revision" : "bda9ed57d72988a3a2ada33d824583541f86eac6", - "version" : "5.12.2" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", - "version" : "11.3.2" - } - }, - { - "identity" : "codescanner", - "kind" : "remoteSourceControl", - "location" : "https://github.com/twostraws/CodeScanner.git", - "state" : { - "revision" : "5e886430238944c7200fc9e10dbf2d9550dba865", - "version" : "2.5.2" - } - }, - { - "identity" : "dropdown", - "kind" : "remoteSourceControl", - "location" : "https://github.com/AssistoLab/DropDown", - "state" : { - "branch" : "master", - "revision" : "2ab6f6ce19f0117d1a76ea043ef8f57722c65d16" - } - }, - { - "identity" : "easytipview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/marinofaggiana/EasyTipView", - "state" : { - "branch" : "master", - "revision" : "bb25a13c8fa5d65144c8e5b5d31237b80a001b07" - } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "8c29ca981990a32e626c8617544fa65d22b1834f", - "version" : "12.19.2" - } - }, - { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee", - "version" : "3.7.0" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f", - "version" : "12.19.2" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", - "version" : "10.1.1" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3", - "version" : "8.1.3" - } - }, - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift", - "state" : { - "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", - "version" : "7.11.1" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", - "version" : "1.69.1" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", - "version" : "5.3.1" - } - }, - { - "identity" : "gzipswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/1024jp/GzipSwift", - "state" : { - "revision" : "41d37421fe2cbbfae4a5593dc29d12999fb73cd7", - "version" : "7.0.0" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" - } - }, - { - "identity" : "keychainaccess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kishikawakatsumi/KeychainAccess", - "state" : { - "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", - "version" : "4.2.2" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "lucidbanner", - "kind" : "remoteSourceControl", - "location" : "https://github.com/marinofaggiana/LucidBanner", - "state" : { - "revision" : "d7ea11cac476f32f63e0ad60f393bc7937b1d019", - "version" : "0.7.1" - } - }, - { - "identity" : "mantis", - "kind" : "remoteSourceControl", - "location" : "https://github.com/marinofaggiana/Mantis", - "state" : { - "revision" : "d145ce2403e0d94d4926eb8510bc4966fbf1bfca", - "version" : "1.0.0" - } - }, - { - "identity" : "markdownkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/bmoliveira/MarkdownKit", - "state" : { - "revision" : "39ea7a3fc7e65bcafe55152592b834859be574a5", - "version" : "1.7.3" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", - "version" : "2.30910.1" - } - }, - { - "identity" : "nextcloudkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/nextcloud/NextcloudKit", - "state" : { - "branch" : "main", - "revision" : "053d6eb161a7024e00e1064ab2825cad3e9317a2" - } - }, - { - "identity" : "openssl", - "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/OpenSSL", - "state" : { - "revision" : "4b1188ba947ddc23d03b13096b9e44b804e146c9", - "version" : "3.6.3000" - } - }, - { - "identity" : "popupview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/exyte/PopupView.git", - "state" : { - "revision" : "e053f5066f0e8feee5e053a50445ac2819e4b937", - "version" : "5.0.6" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", - "version" : "2.4.1" - } - }, - { - "identity" : "qrcodereader.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/yannickl/QRCodeReader.swift", - "state" : { - "revision" : "5020b5a47199d8ba80c83a4b4fafd70e9dc9dc7f", - "version" : "10.1.1" - } - }, - { - "identity" : "realm-core", - "kind" : "remoteSourceControl", - "location" : "https://github.com/realm/realm-core.git", - "state" : { - "revision" : "b4192c46305570577c4d08df790fddec5ab3aa04", - "version" : "20.1.5" - } - }, - { - "identity" : "realm-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/realm/realm-swift", - "state" : { - "revision" : "a40ced79265b644a896e393b0f3a37e7916e7de3", - "version" : "20.0.6" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", - "version" : "1.3.1" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "98ef3c98609a1e31b7e157b5b619579001a789d6", - "version" : "1.7.1" - } - }, - { - "identity" : "swift-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio.git", - "state" : { - "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", - "version" : "2.103.0" - } - }, - { - "identity" : "swift-nio-ssl", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssl", - "state" : { - "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", - "version" : "2.37.5" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", - "state" : { - "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", - "version" : "1.8.1" - } - }, - { - "identity" : "swifter", - "kind" : "remoteSourceControl", - "location" : "https://github.com/httpswift/swifter.git", - "state" : { - "branch" : "stable", - "revision" : "1e4f51c92d7ca486242d8bf0722b99de2c3531aa" - } - }, - { - "identity" : "swiftrichstring", - "kind" : "remoteSourceControl", - "location" : "https://github.com/malcommac/SwiftRichString", - "state" : { - "revision" : "9bf4b5af6bb4386865636fc504d6c588c2b65040", - "version" : "3.7.2" - } - }, - { - "identity" : "swiftyjson", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SwiftyJSON/SwiftyJSON", - "state" : { - "revision" : "af76cf3ef710b6ca5f8c05f3a31307d44a3c5828", - "version" : "5.0.2" - } - }, - { - "identity" : "swiftyxmlparser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/yahoojapan/SwiftyXMLParser", - "state" : { - "revision" : "d7a1d23f04c86c1cd2e8f19247dd15d74e0ea8be", - "version" : "5.6.0" - } - }, - { - "identity" : "taglistview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ElaWorkshop/TagListView", - "state" : { - "revision" : "b30b61826736f4d9ec04aef5df891f3914423f16", - "version" : "1.4.1" - } - }, - { - "identity" : "tlphotopicker", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tilltue/TLPhotoPicker", - "state" : { - "revision" : "91d7bca65721928af30c6a2bb1a1f517f7862964", - "version" : "2.1.21" - } - }, - { - "identity" : "vlckit-spm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tylerjonesio/vlckit-spm", - "state" : { - "revision" : "e932bbd488872fdb74f6654d28c2f291eae03daf", - "version" : "3.6.0" - } - } - ], - "version" : 3 -} diff --git a/iOSClient/Data/NCManageDatabase+Metadata.swift b/iOSClient/Data/NCManageDatabase+Metadata.swift index e458b34d6c..58f63064bf 100644 --- a/iOSClient/Data/NCManageDatabase+Metadata.swift +++ b/iOSClient/Data/NCManageDatabase+Metadata.swift @@ -567,6 +567,19 @@ extension NCManageDatabase { realm.delete(results) } } + + func deleteMetadataOcIds(_ ocIds: [String]) { + do { + let realm = try Realm() + try realm.write { + let results = realm.objects(tableMetadata.self).filter("ocId IN %@", ocIds) + realm.delete(results) + } + } catch let error as NSError { + nkLog(error: "Could not access database: \(error)") + + } + } func renameMetadata(fileNameNew: String, ocId: String, status: Int = NCGlobal.shared.metadataStatusNormal) async { await core.performRealmWriteAsync { realm in diff --git a/iOSClient/Main/Main.storyboard b/iOSClient/Main/Main.storyboard index fbc3b9c855..92a1bd05dc 100644 --- a/iOSClient/Main/Main.storyboard +++ b/iOSClient/Main/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -23,7 +23,8 @@ - + + @@ -105,7 +106,7 @@ - + @@ -115,7 +116,7 @@ - + @@ -132,7 +133,7 @@ - + @@ -142,7 +143,144 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSClient/Media/NCMedia+ScrollViewDelegate.swift b/iOSClient/Media/NCMedia+ScrollViewDelegate.swift index a209cc194f..0315bcfd16 100644 --- a/iOSClient/Media/NCMedia+ScrollViewDelegate.swift +++ b/iOSClient/Media/NCMedia+ScrollViewDelegate.swift @@ -7,11 +7,21 @@ import UIKit extension NCMedia: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { - setTitleDate() - +// setTitleDate() +// +// if !dataSource.compactMetadatas.isEmpty { +// setNeedsStatusBarAppearanceUpdate() +// } if !dataSource.compactMetadatas.isEmpty { + isTop = scrollView.contentOffset.y <= -(insetsTop + view.safeAreaInsets.top - 25) +// setTitleDate() + if lastContentOffsetY == 0 || lastContentOffsetY / 2 <= scrollView.contentOffset.y || lastContentOffsetY / 2 >= scrollView.contentOffset.y { + setTitleDate() + lastContentOffsetY = scrollView.contentOffset.y + } setNeedsStatusBarAppearanceUpdate() } +// setElements() } func scrollViewDidEndDragging( @@ -32,4 +42,9 @@ extension NCMedia: UIScrollViewDelegate { func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { updateImageCacheWindow(force: true) } + + func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { + let y = view.safeAreaInsets.top + scrollView.contentOffset.y = -(insetsTop + y) + } } diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index b602ef1946..f3a0a89f57 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -7,8 +7,23 @@ import UIKit import NextcloudKit import RealmSwift +protocol NCMediaSelectionDelegate: AnyObject { + func didUpdateSelection(files: [String]) +} + class NCMedia: UIViewController { @IBOutlet weak var collectionView: UICollectionView! + @IBOutlet weak var titleDate: UILabel! + @IBOutlet weak var activityIndicator: UIActivityIndicatorView! + @IBOutlet weak var titleConstraint: NSLayoutConstraint! + @IBOutlet weak var gradientView: UIView! + @IBOutlet weak var gradientViewHeightContsraint: NSLayoutConstraint! + + // Called when initial media data has finished loading + var onInitialLoadCompleted: (() -> Void)? + + let semaphoreSearchMedia = DispatchSemaphore(value: 1) + let semaphoreNotificationCenter = DispatchSemaphore(value: 1) let layout = NCMediaLayout() let gradientLayer = CAGradientLayer() @@ -22,19 +37,39 @@ class NCMedia: UIViewController { let imageCache = NCImageCache.shared let networking = NCNetworking.shared var dataSource = NCMediaDataSource() + let refreshControl = UIRefreshControl() + var isTop: Bool = true var isEditMode = false - var fileSelect: [String] = [] +// var fileSelect: [String] = [] + // 1. Add this property here (NOT in an extension) + weak var selectionDelegate: NCMediaSelectionDelegate? + + // 2. Find your existing fileSelect array and add the didSet + var fileSelect: [String] = [] { + didSet { + selectionDelegate?.didUpdateSelection(files: fileSelect) + } + } + var filesExists: ThreadSafeArray = ThreadSafeArray() + var ocIdDoNotExists: ThreadSafeArray = ThreadSafeArray() +// var searchMediaInProgress: Bool = false + // Tracks whether we have completed an explicit preload before presentation + private var didCompleteInitialPreload = false + private var explicitPreloadTask: Task? + var attributesZoomIn: UIMenuElement.Attributes = [] var attributesZoomOut: UIMenuElement.Attributes = [] var showOnlyImages = false var showOnlyVideos = false var timeIntervalSearchNewMedia: TimeInterval = 2.0 var timerSearchNewMedia: Timer? + let insetsTop: CGFloat = 0//75//65 let livePhotoImage = NCUtility().loadImage(named: "livephoto", colors: [.white]) let playImage = NCUtility().loadImage(named: "play.fill", colors: [.white]) var photoImage = UIImage() var videoImage = UIImage() var pinchGesture: UIPinchGestureRecognizer = UIPinchGestureRecognizer() + var metadatas: ThreadSafeArray? var lastScale: CGFloat = 1.0 var currentScale: CGFloat = 1.0 @@ -52,7 +87,6 @@ class NCMedia: UIViewController { numberOfColumns != oldValue else { return } - let oldExtension = global.getSizeExtension(column: oldValue) let newExtension = global.getSizeExtension(column: numberOfColumns) @@ -63,6 +97,15 @@ class NCMedia: UIViewController { imageCache.removeAll() } } + var loadingTask: Task? + var mediaCommandView: NCMediaCommandView? + var activeAccount = tableAccount() + var lastContentOffsetY: CGFloat = 0 + let maxImageGrid: CGFloat = 7 + var hiddenCellMetadats: ThreadSafeArray = ThreadSafeArray() + + var isInGeneralPhotosSelectionContext: Bool = false + var imageLoadingTasks: [String: Task] = [:] let debouncerLoadDataSource = NCDebouncer(delay: .seconds(3), maxEventCount: 10) let debouncerSearch = NCDebouncer(delay: .seconds(2), maxEventCount: 10) @@ -113,7 +156,6 @@ class NCMedia: UIViewController { NCSession.shared.getSession(controller: tabBarController) } - @MainActor var controller: NCMainTabBarController? { self.tabBarController as? NCMainTabBarController } @@ -133,7 +175,6 @@ class NCMedia: UIViewController { return pinchGesture.state == .began || pinchGesture.state == .changed } - @MainActor var sceneIdentifier: String { (self.tabBarController as? NCMainTabBarController)?.sceneIdentifier ?? "" } @@ -142,6 +183,31 @@ class NCMedia: UIViewController { internal var windowScene: UIWindowScene? { SceneManager.shared.getWindowScene(controller: self.tabBarController as? NCMainTabBarController) } + +// var isInGeneralPhotosSelectionContext: Bool = false + + // MARK: - Programmatic Preload API + /// Preloads the media data (data source and initial search) so that the controller is ready when presented. + /// Safe to call while the media tab hasn't been opened yet. Idempotent across multiple calls. + @MainActor + func preloadIfNeeded() { + // Avoid re-running if already completed + if didCompleteInitialPreload { return } + // Cancel any previous explicit preload + explicitPreloadTask?.cancel() + explicitPreloadTask = Task { [weak self] in + guard let self else { return } + // Ensure view is loaded to set up collectionView/layout safely + _ = self.view + // Run the same loading sequence used in view lifecycle, but explicitly + await self.loadDataSource() + await self.searchMediaUI(true) + self.didCompleteInitialPreload = true + await MainActor.run { + self.onInitialLoadCompleted?() + } + } + } // MARK: - View Life Cycle @@ -149,6 +215,7 @@ class NCMedia: UIViewController { super.viewDidLoad() view.backgroundColor = .systemBackground + navigationController?.setNavigationBarAppearance() collectionView.register(UINib(nibName: "NCSectionFirstHeaderEmptyData", bundle: nil), forSupplementaryViewOfKind: mediaSectionHeader, withReuseIdentifier: "sectionFirstHeaderEmptyData") collectionView.register(UINib(nibName: "NCMediaSectionHeader", bundle: nil), forSupplementaryViewOfKind: mediaSectionHeader, withReuseIdentifier: "sectionHeader") @@ -156,7 +223,9 @@ class NCMedia: UIViewController { collectionView.register(UINib(nibName: "NCMediaCell", bundle: nil), forCellWithReuseIdentifier: "mediaCell") collectionView.alwaysBounceVertical = true collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + collectionView.contentInset = isInGeneralPhotosSelectionContext ? UIEdgeInsets(top: 10, left: 0, bottom: 50, right: 0) : UIEdgeInsets(top: insetsTop, left: 0, bottom: 50, right: 0) collectionView.backgroundColor = .systemBackground + collectionView.prefetchDataSource = self collectionView.dragInteractionEnabled = true collectionView.dragDelegate = self collectionView.dropDelegate = self @@ -165,8 +234,16 @@ class NCMedia: UIViewController { layout.sectionInset = UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2) layout.overlaysSectionHeader = true collectionView.collectionViewLayout = layout +// layoutType = database.getLayoutForView(account: session.account, key: global.layoutViewMedia, serverUrl: "", layout: global.mediaLayoutRatio).layout layoutType = database.getLayoutForView(account: session.account, key: global.layoutViewMedia, serverUrl: "", layoutType: global.mediaLayoutRatio).layout +// tabBarSelect = NCMediaSelectTabBar(controller: self.tabBarController, viewController: self, delegate: self) + + titleDate.text = "" + titleDate.isHidden = true + + isEditMode = isInGeneralPhotosSelectionContext ? true : false + // Gradient Layer gradientLayer.startPoint = CGPoint(x: 0, y: 0) gradientLayer.endPoint = CGPoint(x: 0, y: 1) @@ -184,6 +261,36 @@ class NCMedia: UIViewController { navigationItem.leftItemsSupplementBackButton = true navigationItem.leftBarButtonItem = nil + gradientLayer.locations = [0.0, 0.20, 0.40, 0.60, 0.75, 0.85, 0.95, 1.0] + gradientView.layer.insertSublayer(gradientLayer, at: 0) + + activeAccount = NCManageDatabase.shared.getActiveTableAccount() ?? tableAccount() + + collectionView.refreshControl = refreshControl + refreshControl.action(for: .valueChanged) { _ in + DispatchQueue.global().async { + Task { + await self.loadDataSource() + await self.searchMediaUI(true) + } + } + self.refreshControl.endRefreshing() + } + + // Title + Activity indicator + if UIDevice.current.userInterfaceIdiom == .pad { + titleConstraint.constant = 0 + } else { + if #available(iOS 26.0, *) { + titleConstraint.constant = -44 + } else { + titleConstraint.constant = -34 + } + } + + titleDate.text = "" + titleDate?.textColor = .white + activityIndicator.color = .white pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) collectionView.addGestureRecognizer(pinchGesture) @@ -217,11 +324,24 @@ class NCMedia: UIViewController { guard let self else { return } - Task { await self.networkRemoveAll() } } + + NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterClearCache), object: nil, queue: nil) { _ in + Task { + await self.dataSource.clearCompactMetadatas() + self.imageCache.removeAll() + await self.searchMediaUI(true) + } + } + + NotificationCenter.default.addObserver(self, selector: #selector(fileExists(_:)), name: NSNotification.Name(rawValue: global.notificationCenterFileExists), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(deleteFile(_:)), name: NSNotification.Name(rawValue: global.notificationCenterDeleteFile), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(reloadDataSource(_:)), name: NSNotification.Name(rawValue: global.notificationCenterReloadDataSource), object: nil) } override func viewWillAppear(_ animated: Bool) { @@ -234,6 +354,7 @@ class NCMedia: UIViewController { delegate: self ) } +// navigationController?.setMediaAppreance() Task { [weak self] in guard let self else { @@ -270,16 +391,37 @@ class NCMedia: UIViewController { self.updateImageCacheWindow() self.setTitleDate() } + Task { + if !self.didCompleteInitialPreload { + await self.loadDataSource() + await self.searchMediaUI(true) + } + } +// AnalyticsHelper.shared.trackEvent(eventName: .SCREEN_EVENT__MEDIA) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + // Re-evaluate in-app messages after viewDidAppear +// MoEngageAnalytics.shared.displayInAppNotificationSafely(reason: "viewDidAppear") + + Task { + await networking.transferDispatcher.addDelegate(self) + } + + NotificationCenter.default.addObserver(self, selector: #selector(copyMoveFile(_:)), name: NSNotification.Name(rawValue: global.notificationCenterCopyMoveFile), object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(enterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) Task { [weak self] in await self?.loadDataSource() } + if !didCompleteInitialPreload { + onInitialLoadCompleted?() + } + + searchNewMedia() } override func viewDidDisappear(_ animated: Bool) { @@ -305,21 +447,34 @@ class NCMedia: UIViewController { await self.networkRemoveAll() } - NotificationCenter.default.removeObserver( - self, - name: UIApplication.willEnterForegroundNotification, - object: nil - ) + NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: global.notificationCenterCopyMoveFile), object: nil) + + NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil) } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() setTitleDate() +// if let frame = tabBarController?.tabBar.frame { +// tabBarSelect.hostingController?.view.frame = frame +// } + gradientLayer.frame = gradientView.bounds + } - // MARK: - Timer search media + override var preferredStatusBarStyle: UIStatusBarStyle { + if self.traitCollection.userInterfaceStyle == .dark { + return .lightContent + } else if isTop { + return .darkContent + } else { + return .lightContent + } + } + // MARK: - Timer search media + func searchNewMedia() { timerSearchNewMedia?.invalidate() @@ -342,6 +497,10 @@ class NCMedia: UIViewController { func networkRemoveAll() async { timerSearchNewMedia?.invalidate() timerSearchNewMedia = nil + filesExists.removeAll() + + NCNetworking.shared.fileExistsQueue.cancel() + NCNetworking.shared.downloadThumbnailQueue.cancel() let tasks = await networking.getAllDataTask() for task in tasks.filter({ $0.taskDescription == global.taskDescriptionRetrievesProperties }) { @@ -349,6 +508,47 @@ class NCMedia: UIViewController { } } + @objc func reloadDataSource(_ notification: NSNotification) { + Task { + await self.loadDataSource() + } + } + + @objc func deleteFile(_ notification: NSNotification) { + guard let userInfo = notification.userInfo as NSDictionary?, + let error = userInfo["error"] as? NKError + else { + return + } + + // This is only a fail safe "dead lock", I don't think the timeout will ever be called but at least nothing gets stuck, if after 5 sec. (which is a long time in this routine), the semaphore is still locked + // +// if self.semaphoreNotificationCenter.wait(timeout: .now() + 5) == .timedOut { +// self.semaphoreNotificationCenter.signal() +// } + + if error.errorCode == self.global.errorResourceNotFound, + let ocIds = userInfo["ocId"] as? [String], + let ocId = ocIds.first { + Task { + await NCManageDatabase.shared.deleteMetadataAsync(ocId: ocId) + await self.loadDataSource() +// { +// self.semaphoreNotificationCenter.signal() +// } + } + } else if error != .success { + Task { + await self.loadDataSource() + } +// self.loadDataSource { +// self.semaphoreNotificationCenter.signal() +// } + } else { +// semaphoreNotificationCenter.signal() + } + } + @objc func enterForeground(_ notification: NSNotification) { Task { [weak self] in await self?.loadDataSource() @@ -356,6 +556,44 @@ class NCMedia: UIViewController { searchNewMedia() } + @objc func fileExists(_ notification: NSNotification) { + guard let userInfo = notification.userInfo as NSDictionary?, + let ocId = userInfo["ocId"] as? String, + let fileExists = userInfo["fileExists"] as? Bool + else { + return + } + + filesExists.append(ocId) + if !fileExists { + ocIdDoNotExists.append(ocId) + } + + if NCNetworking.shared.fileExistsQueue.operationCount == 0, + !ocIdDoNotExists.isEmpty, + let ocIdDoNotExists = self.ocIdDoNotExists.getArray() { + dataSource.removeCompactMetadata(ocIdDoNotExists) + database.deleteMetadataOcIds(ocIdDoNotExists) + self.ocIdDoNotExists.removeAll() + collectionViewReloadData() + } + } + + @objc func copyMoveFile(_ notification: NSNotification) { + guard let userInfo = notification.userInfo as NSDictionary?, + let dragDrop = userInfo["dragdrop"] as? Bool, + dragDrop else { return } + + setEditMode(false) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + Task { + await self.loadDataSource() + await self.searchMediaUI() + } + } + } + func buildMediaPhotoVideo(columnCount: Int) { var pointSize: CGFloat = 0 @@ -403,4 +641,152 @@ class NCMedia: UIViewController { force: force ) } + + // MARK: - Command + + func setupMediaCommandView() { + mediaCommandView?.title.text = "" + + mediaCommandView = Bundle.main.loadNibNamed("NCMediaCommandView", owner: self, options: nil)?.first as? NCMediaCommandView + self.view.addSubview(mediaCommandView!) + mediaCommandView?.mediaView = self +// updateZoomButton() + mediaCommandView?.collapseControlButtonView(true) + mediaCommandView?.translatesAutoresizingMaskIntoConstraints = false + mediaCommandView?.topAnchor.constraint(equalTo: view.topAnchor, constant: 0).isActive = true + mediaCommandView?.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0).isActive = true + mediaCommandView?.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0).isActive = true + mediaCommandView?.heightAnchor.constraint(equalToConstant: 150).isActive = true + self.updateMediaControlVisibility() + } + + private func setupForGeneralPhotosSelection() { + if isInGeneralPhotosSelectionContext { + gradientViewHeightContsraint.constant = 0 + mediaCommandView?.setupForGeneralPhotosSelection() + isEditMode = true + } + } + + func updateMediaControlVisibility() { + + if let metadatas = self.metadatas, metadatas.isEmpty { + if !self.showOnlyImages && !self.showOnlyVideos { + self.mediaCommandView?.toggleEmptyView(isEmpty: true) + self.mediaCommandView?.isHidden = false + } else { + self.mediaCommandView?.toggleEmptyView(isEmpty: true) + self.mediaCommandView?.isHidden = false + } + } else { + self.mediaCommandView?.toggleEmptyView(isEmpty: false) + self.mediaCommandView?.isHidden = false + } + } } + +// MARK: - Media Command View + +class NCMediaCommandView: UIView { + + @IBOutlet weak var moreView: UIVisualEffectView! + @IBOutlet weak var gridSwitchButton: UIButton! + @IBOutlet weak var separatorView: UIView! + @IBOutlet weak var buttonControlWidthConstraint: NSLayoutConstraint! + @IBOutlet weak var zoomInButton: UIButton! + @IBOutlet weak var zoomOutButton: UIButton! + @IBOutlet weak var moreButton: UIButton! + @IBOutlet weak var controlButtonView: UIVisualEffectView! + @IBOutlet weak var title: UILabel! + @IBOutlet weak var activityIndicator: UIActivityIndicatorView! + + var mediaView: NCMedia? + private let gradient: CAGradientLayer = CAGradientLayer() + + override func awakeFromNib() { + moreView.layer.cornerRadius = 20 + moreView.layer.masksToBounds = true + controlButtonView.layer.cornerRadius = 20 + controlButtonView.layer.masksToBounds = true + controlButtonView.effect = UIBlurEffect(style: .dark) + gradient.frame = bounds + gradient.startPoint = CGPoint(x: 0, y: 0.5) + gradient.endPoint = CGPoint(x: 0, y: 1) + gradient.colors = [UIColor.black.withAlphaComponent(UIAccessibility.isReduceTransparencyEnabled ? 0.8 : 0.4).cgColor, UIColor.clear.cgColor] + layer.insertSublayer(gradient, at: 0) + moreButton.setImage(UIImage(named: "more")!.image(color: .white, size: 25), for: .normal) + title.text = "" + } + + func setupForGeneralPhotosSelection() { + gridSwitchButton.isHidden = true + moreView.isHidden = true + title.isHidden = true + controlButtonView.isHidden = true + gradient.isHidden = true + } + + func toggleEmptyView(isEmpty: Bool) { + if isEmpty { + UIView.animate(withDuration: 0.3) { + self.moreView.effect = UIBlurEffect(style: .dark) + self.gradient.isHidden = true + self.controlButtonView.isHidden = true + } + } else { + UIView.animate(withDuration: 0.3) { + self.moreView.effect = UIBlurEffect(style: .dark) + self.gradient.isHidden = false + self.controlButtonView.isHidden = false + } + } + } + + @IBAction func moreButtonPressed(_ sender: UIButton) { +// mediaView?.openMenuButtonMore(sender) + } + + @IBAction func zoomInPressed(_ sender: UIButton) { +// mediaView?.zoomInGrid() + } + + @IBAction func zoomOutPressed(_ sender: UIButton) { +// mediaView?.zoomOutGrid() + } + + @IBAction func gridSwitchButtonPressed(_ sender: Any) { + self.collapseControlButtonView(false) + } + + func collapseControlButtonView(_ collapse: Bool) { + if collapse { + self.buttonControlWidthConstraint.constant = 40 + UIView.animate(withDuration: 0.25) { + self.zoomOutButton.isHidden = true + self.zoomInButton.isHidden = true + self.separatorView.isHidden = true + self.gridSwitchButton.isHidden = false + self.layoutIfNeeded() + } + } else { + self.buttonControlWidthConstraint.constant = 80 + UIView.animate(withDuration: 0.25) { + self.zoomOutButton.isHidden = false + self.zoomInButton.isHidden = false + self.separatorView.isHidden = false + self.gridSwitchButton.isHidden = true + self.layoutIfNeeded() + } + } + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return moreView.frame.contains(point) || controlButtonView.frame.contains(point) + } + + override func layoutSublayers(of layer: CALayer) { + super.layoutSublayers(of: layer) + gradient.frame = bounds + } +} + diff --git a/iOSClient/Media/NCMediaHost.swift b/iOSClient/Media/NCMediaHost.swift new file mode 100644 index 0000000000..d6f37f5518 --- /dev/null +++ b/iOSClient/Media/NCMediaHost.swift @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Your Name +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit + +struct NCMediaHost: UIViewControllerRepresentable { + var storyboardName: String = "NCMedia" // <- adjust if different + var sceneIdentifier: String = "NCMedia.storyboard" // <- set to your storyboard ID + var onLoaded: (() -> Void)? + var onSelectionChange: (([String]) -> Void)? + /// Called when the host's consumer taps Done in a surrounding toolbar and wants to commit selection + var onDone: (([String]) -> Void)? + /// Called when the host's consumer taps Back/Cancel + var onCancel: (() -> Void)? + var isSelectionContext: Bool = false + + init( + storyboardName: String = "NCMedia", + sceneIdentifier: String = "NCMedia.storyboard", + isSelectionContext: Bool = false, + onLoaded: (() -> Void)? = nil, + onSelectionChange: (([String]) -> Void)? = nil, + onDone: (([String]) -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.storyboardName = storyboardName + self.sceneIdentifier = sceneIdentifier + self.isSelectionContext = isSelectionContext + self.onLoaded = onLoaded + self.onSelectionChange = onSelectionChange + self.onDone = onDone + self.onCancel = onCancel + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> UINavigationController { + // 1) Storyboard-based instantiation (preferred) + let sb = UIStoryboard(name: storyboardName, bundle: .main) + guard let mediaVC = sb.instantiateViewController(withIdentifier: sceneIdentifier) as? NCMedia else { + fatalError("Failed to instantiate NCMedia from storyboard. Ensure storyboardName '\(storyboardName)' and sceneIdentifier '\(sceneIdentifier)' are correct, and the class is NCMedia.") + } + + // 2) Hook completion and preload + mediaVC.onInitialLoadCompleted = { onLoaded?() } + mediaVC.selectionDelegate = context.coordinator + mediaVC.isInGeneralPhotosSelectionContext = true + mediaVC.isEditMode = true + Task { @MainActor in + mediaVC.preloadIfNeeded() + } + + let nav = UINavigationController(rootViewController: mediaVC) +// nav.modalPresentationStyle = .formSheet + nav.isNavigationBarHidden = true + return nav + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { + // No-op + } + + class Coordinator: NSObject, NCMediaSelectionDelegate { + let parent: NCMediaHost + private(set) var latestSelection: [String] = [] + + init(parent: NCMediaHost) { + self.parent = parent + } + + func didUpdateSelection(files: [String]) { + latestSelection = files + parent.onSelectionChange?(files) + } + } +} + +/// Example SwiftUI usage: Present media in a sheet on button tap, preloading content even if the Media tab hasn't been opened. +struct PreloadMediaSheetExample: View { + @State private var showMedia = false + @State private var isLoaded = false + + var body: some View { + VStack(spacing: 20) { + Button("Open Media (Preloaded)") { + // Trigger the sheet; the representable will call preload + showMedia = true + } + .buttonStyle(.borderedProminent) + + if isLoaded { + Text("Media is ready").font(.footnote).foregroundStyle(.secondary) + } + } + .sheet(isPresented: $showMedia) { + NCMediaHost { + // This fires when NCMedia finished its first data load + isLoaded = true + } + .ignoresSafeArea() + } + } +} + +extension NCMediaHost.Coordinator { + func currentSelection() -> [String] { latestSelection } +} + +#Preview { + PreloadMediaSheetExample() +} diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index 6d830a914b..ed6414252c 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -261,6 +261,38 @@ final class NCGlobal: Sendable { let notificationCenterServerDidUpdate = "serverDidUpdate" // userInfo: account let notificationCenterNetworkReachability = "networkReachability" + let notificationCenterClearCache = "clearCache" + let notificationCenterCreateMediaCacheEnded = "createMediaCacheEnded" + let notificationCenterUpdateNotification = "updateNotification" + + let notificationCenterDeleteFile = "deleteFile" // userInfo: [ocId], error + let notificationCenterCopyMoveFile = "copyMoveFile" // userInfo: [ocId] serverUrl, account, dragdrop, type (copy, move) + let notificationCenterMoveFile = "moveFile" // userInfo: [ocId], [indexPath], error + let notificationCenterCopyFile = "copyFile" // userInfo: [ocId], [indexPath], error + let notificationCenterRenameFile = "renameFile" // userInfo: serverUrl, account, error + let notificationCenterFavoriteFile = "favoriteFile" // userInfo: ocId, serverUrl + let notificationCenterFileExists = "fileExists" // userInfo: ocId, fileExists + let notificationCenterReloadDataSource = "reloadDataSource" // userInfo: serverUrl?, clearDataSource + + let notificationCenterGetServerData = "getServerData" // userInfo: serverUrl? + + let notificationCenterChangeStatusFolderE2EE = "changeStatusFolderE2EE" // userInfo: serverUrl + + let notificationCenterDownloadStartFile = "downloadStartFile" // userInfo: ocId, ocIdTransfer, session, serverUrl, account + let notificationCenterDownloadedFile = "downloadedFile" // userInfo: ocId, ocIdTransfer, session, session, serverUrl, account, selector, error + let notificationCenterDownloadCancelFile = "downloadCancelFile" // userInfo: ocId, ocIdTransfer, session, serverUrl, account + + let notificationCenterUploadStartFile = "uploadStartFile" // userInfo: ocId, ocIdTransfer, session, serverUrl, account, fileName, sessionSelector + let notificationCenterUploadedFile = "uploadedFile" // userInfo: ocId, ocIdTransfer, session, serverUrl, account, fileName, ocIdTransfer, error + let notificationCenterUploadedLivePhoto = "uploadedLivePhoto" // userInfo: ocId, ocIdTransfer, session, serverUrl, account, fileName, ocIdTransfer, error + let notificationCenterUploadCancelFile = "uploadCancelFile" // userInfo: ocId, ocIdTransfer, session, serverUrl, account + + let notificationCenterProgressTask = "progressTask" // userInfo: account, ocId, ocIdTransfer, session, serverUrl, status, chunk, e2eEncrypted, progress, totalBytes, totalBytesExpected + + let notificationCenterUpdateBadgeNumber = "updateBadgeNumber" // userInfo: counterDownload, counterUpload + + let notificationCenterCreateFolder = "createFolder" // userInfo: ocId, serverUrl, account, withPush, sceneIdentifier + let notificationCenterMenuSearchTextPDF = "menuSearchTextPDF" let notificationCenterMenuGotToPageInPDF = "menuGotToPageInPDF" diff --git a/iOSClient/Networking/NCNetworking.swift b/iOSClient/Networking/NCNetworking.swift index 9e4e518436..58293ccf3f 100644 --- a/iOSClient/Networking/NCNetworking.swift +++ b/iOSClient/Networking/NCNetworking.swift @@ -10,6 +10,7 @@ import SwiftUI import UIKit import NextcloudKit import Alamofire +import Queuer protocol ClientCertificateDelegate: AnyObject { func onIncorrectPassword() @@ -96,6 +97,10 @@ class NCNetworking: @unchecked Sendable, NextcloudKitDelegate { #if !EXTENSION let metadataDownloadTranfersSuccess = NCMetadataDownloadTranfersSuccess() let metadataUploadTranfersSuccess = NCMetadataUploadTranfersSuccess() + // OPERATIONQUEUE + let downloadThumbnailQueue = Queuer(name: "downloadThumbnailQueue", maxConcurrentOperationCount: 10, qualityOfService: .default) + let fileExistsQueue = Queuer(name: "fileExistsQueue", maxConcurrentOperationCount: 10, qualityOfService: .default) + #endif // MARK: - init From ca56c148d8c6c8a8eed62760bab2d5c6123658ef Mon Sep 17 00:00:00 2001 From: harshada-15-tsys Date: Thu, 30 Apr 2026 18:29:29 +0530 Subject: [PATCH 2/4] NMC 3733 - Albums on mobile app customisation --- iOSClient/Media/NCMedia.swift | 300 +++++++++++----------------------- 1 file changed, 93 insertions(+), 207 deletions(-) diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index f3a0a89f57..8e404dd6a2 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -52,7 +52,7 @@ class NCMedia: UIViewController { } var filesExists: ThreadSafeArray = ThreadSafeArray() var ocIdDoNotExists: ThreadSafeArray = ThreadSafeArray() -// var searchMediaInProgress: Bool = false + var searchMediaInProgress: Bool = false // Tracks whether we have completed an explicit preload before presentation private var didCompleteInitialPreload = false private var explicitPreloadTask: Task? @@ -75,28 +75,13 @@ class NCMedia: UIViewController { var currentScale: CGFloat = 1.0 var maxColumns: Int { let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) - let column = Int(screenWidth / 55) + let column = Int(screenWidth / 44) return column } var transitionColumns = false + var numberOfColumns: Int = 0 var lastNumberOfColumns: Int = 0 - var numberOfColumns: Int = 0 { - didSet { - guard oldValue > 0, - numberOfColumns != oldValue else { - return - } - let oldExtension = global.getSizeExtension(column: oldValue) - let newExtension = global.getSizeExtension(column: numberOfColumns) - - guard oldExtension != newExtension else { - return - } - - imageCache.removeAll() - } - } var loadingTask: Task? var mediaCommandView: NCMediaCommandView? var activeAccount = tableAccount() @@ -106,50 +91,8 @@ class NCMedia: UIViewController { var isInGeneralPhotosSelectionContext: Bool = false - var imageLoadingTasks: [String: Task] = [:] - let debouncerLoadDataSource = NCDebouncer(delay: .seconds(3), maxEventCount: 10) - let debouncerSearch = NCDebouncer(delay: .seconds(2), maxEventCount: 10) - - struct CollectionViewScrollAnchor { - let ocId: String - let deltaX: CGFloat - let deltaY: CGFloat - } - - var searchMediaTask: Task? - var buildDataSourceTask: Task? - - var datasourceMediaInProgress: Bool = false - var searchMediaInProgress: Bool = false { - didSet { - guard oldValue != searchMediaInProgress else { - return - } - - updateLeftBarButtonItems( - date: navigationItem.leftBarButtonItems?.first === buttonDateBarItem ? buttonDateBarItem : nil, - activity: searchMediaInProgress - ) - } - } - - internal lazy var buttonDateBarItem = UIBarButtonItem( - title: nil, - style: .plain, - target: self, - action: #selector(presentMediaDatePicker) - ) - internal var lastVisibleDateRange: (first: IndexPath, last: IndexPath)? - - internal lazy var searchActivityIndicator: UIActivityIndicatorView = { - let activityIndicator = UIActivityIndicatorView(style: .medium) - activityIndicator.hidesWhenStopped = true - return activityIndicator - }() - - internal lazy var searchActivityBarButtonItem: UIBarButtonItem = { - UIBarButtonItem(customView: searchActivityIndicator) - }() + let debouncerLoadDataSource = NCDebouncer(maxEventCount: 10) + let debouncerSearch = NCDebouncer(maxEventCount: 10) @MainActor var session: NCSession.Session { @@ -164,13 +107,6 @@ class NCMedia: UIViewController { return self.isViewLoaded && self.view.window != nil } - // Album selection presents Media in a sheet and keeps searching while selecting. - var allowsSearchWhileSelecting: Bool { false } - - var isMediaPresentationActive: Bool { - isViewActived && tabBarController?.selectedViewController === navigationController - } - var isPinchGestureActive: Bool { return pinchGesture.state == .began || pinchGesture.state == .changed } @@ -179,11 +115,6 @@ class NCMedia: UIViewController { (self.tabBarController as? NCMainTabBarController)?.sceneIdentifier ?? "" } - @MainActor - internal var windowScene: UIWindowScene? { - SceneManager.shared.getWindowScene(controller: self.tabBarController as? NCMainTabBarController) - } - // var isInGeneralPhotosSelectionContext: Bool = false // MARK: - Programmatic Preload API @@ -218,7 +149,6 @@ class NCMedia: UIViewController { navigationController?.setNavigationBarAppearance() collectionView.register(UINib(nibName: "NCSectionFirstHeaderEmptyData", bundle: nil), forSupplementaryViewOfKind: mediaSectionHeader, withReuseIdentifier: "sectionFirstHeaderEmptyData") - collectionView.register(UINib(nibName: "NCMediaSectionHeader", bundle: nil), forSupplementaryViewOfKind: mediaSectionHeader, withReuseIdentifier: "sectionHeader") collectionView.register(UINib(nibName: "NCSectionFooter", bundle: nil), forSupplementaryViewOfKind: mediaSectionFooter, withReuseIdentifier: "sectionFooter") collectionView.register(UINib(nibName: "NCMediaCell", bundle: nil), forCellWithReuseIdentifier: "mediaCell") collectionView.alwaysBounceVertical = true @@ -230,12 +160,11 @@ class NCMedia: UIViewController { collectionView.dragDelegate = self collectionView.dropDelegate = self collectionView.accessibilityIdentifier = "NCMedia" + // collectionView.contentInsetAdjustmentBehavior = .never layout.sectionInset = UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2) - layout.overlaysSectionHeader = true collectionView.collectionViewLayout = layout -// layoutType = database.getLayoutForView(account: session.account, key: global.layoutViewMedia, serverUrl: "", layout: global.mediaLayoutRatio).layout - layoutType = database.getLayoutForView(account: session.account, key: global.layoutViewMedia, serverUrl: "", layoutType: global.mediaLayoutRatio).layout + layoutType = database.getLayoutForView(account: session.account, key: global.layoutViewMedia, serverUrl: "", layout: global.mediaLayoutRatio).layout // tabBarSelect = NCMediaSelectTabBar(controller: self.tabBarController, viewController: self, delegate: self) @@ -259,8 +188,6 @@ class NCMedia: UIViewController { UIColor.clear.cgColor ] - navigationItem.leftItemsSupplementBackButton = true - navigationItem.leftBarButtonItem = nil gradientLayer.locations = [0.0, 0.20, 0.40, 0.60, 0.75, 0.85, 0.95, 1.0] gradientView.layer.insertSublayer(gradientLayer, at: 0) @@ -295,14 +222,7 @@ class NCMedia: UIViewController { pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) collectionView.addGestureRecognizer(pinchGesture) - Task { - await loadDataSource() - } - - NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterChangeUser), object: nil, queue: nil) { [weak self] notification in - guard let self else { - return - } + NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterChangeUser), object: nil, queue: nil) { notification in Task { @MainActor in guard let userInfo = notification.userInfo, let account = userInfo["account"] as? String else { @@ -310,86 +230,52 @@ class NCMedia: UIViewController { } self.layoutType = self.database.getLayoutForView(account: account, key: self.global.layoutViewMedia, serverUrl: "").layout - self.imageCache.removeAll() - self.dataSource.clearCompactMetadatas() - self.setTitleDate() - - await self.loadDataSource(forced: true) + await self.loadDataSource() await self.searchMediaUI(true) } } - NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in - guard let self else { - return - } - Task { - await self.networkRemoveAll() - } - } - NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterClearCache), object: nil, queue: nil) { _ in Task { - await self.dataSource.clearCompactMetadatas() + await self.dataSource.clearMetadatas() self.imageCache.removeAll() await self.searchMediaUI(true) } } - + NotificationCenter.default.addObserver(self, selector: #selector(fileExists(_:)), name: NSNotification.Name(rawValue: global.notificationCenterFileExists), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(deleteFile(_:)), name: NSNotification.Name(rawValue: global.notificationCenterDeleteFile), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadDataSource(_:)), name: NSNotification.Name(rawValue: global.notificationCenterReloadDataSource), object: nil) + + NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { _ in + Task { + await self.networkRemoveAll() + } + } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) if tabBarSelect == nil { - tabBarSelect = NCMediaSelectTabBar( - controller: self.tabBarController, - viewController: self, - delegate: self - ) + tabBarSelect = NCMediaSelectTabBar(controller: self.tabBarController, viewController: self, delegate: self) } // navigationController?.setMediaAppreance() - Task { [weak self] in - guard let self else { - return - } - - await (self.navigationController as? NCMediaNavigationController)? - .setNavigationRightItems() - + Task { + await (self.navigationController as? NCMediaNavigationController)?.setNavigationRightItems() if #unavailable(iOS 26.0) { - (self.navigationController as? NCMediaNavigationController)? - .updateRightBarButtonsTint(to: .white) + (self.navigationController as? NCMediaNavigationController)?.updateRightBarButtonsTint(to: .white) } } - Task { [weak self] in - guard let self else { - return - } - - await self.networking.transferDispatcher.addDelegate(self) - - guard !Task.isCancelled else { - return - } - - self.searchNewMedia() - - guard !Task.isCancelled else { - return + if dataSource.metadatas.isEmpty { + Task { + await loadDataSource() } - - self.collectionView.layoutIfNeeded() - self.updateImageCacheWindow() - self.setTitleDate() } Task { if !self.didCompleteInitialPreload { @@ -397,14 +283,14 @@ class NCMedia: UIViewController { await self.searchMediaUI(true) } } -// AnalyticsHelper.shared.trackEvent(eventName: .SCREEN_EVENT__MEDIA) + AnalyticsHelper.shared.trackEvent(eventName: .SCREEN_EVENT__MEDIA) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // Re-evaluate in-app messages after viewDidAppear -// MoEngageAnalytics.shared.displayInAppNotificationSafely(reason: "viewDidAppear") + MoEngageAnalytics.shared.displayInAppNotificationSafely(reason: "viewDidAppear") Task { await networking.transferDispatcher.addDelegate(self) @@ -427,24 +313,9 @@ class NCMedia: UIViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - searchMediaTask?.cancel() - searchMediaTask = nil - - buildDataSourceTask?.cancel() - buildDataSourceTask = nil - - imageCache.removeAll() - - Task { [weak self] in - guard let self else { - return - } - - await self.debouncerSearch.cancel() - await self.debouncerLoadDataSource.cancel() - - await self.networking.transferDispatcher.removeDelegate(self) - await self.networkRemoveAll() + Task { + await networking.transferDispatcher.removeDelegate(self) + await networkRemoveAll() } NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: global.notificationCenterCopyMoveFile), object: nil) @@ -452,15 +323,13 @@ class NCMedia: UIViewController { NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil) } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() - setTitleDate() // if let frame = tabBarController?.tabBar.frame { // tabBarSelect.hostingController?.view.frame = frame // } gradientLayer.frame = gradientView.bounds - } override var preferredStatusBarStyle: UIStatusBarStyle { @@ -473,20 +342,11 @@ class NCMedia: UIViewController { } } - // MARK: - Timer search media - func searchNewMedia() { timerSearchNewMedia?.invalidate() - timerSearchNewMedia = Timer.scheduledTimer(withTimeInterval: timeIntervalSearchNewMedia, repeats: false) { [weak self] _ in - guard let self else { - return - } - self.searchMediaTask?.cancel() - self.searchMediaTask = Task { [weak self] in - guard let self else { - return - } + Task { [weak self] in + guard let self else { return } await self.searchMediaUI() } } @@ -499,8 +359,8 @@ class NCMedia: UIViewController { timerSearchNewMedia = nil filesExists.removeAll() - NCNetworking.shared.fileExistsQueue.cancel() - NCNetworking.shared.downloadThumbnailQueue.cancel() + NCNetworking.shared.fileExistsQueue.cancelAll() + networking.downloadThumbnailQueue.cancelAll() let tasks = await networking.getAllDataTask() for task in tasks.filter({ $0.taskDescription == global.taskDescriptionRetrievesProperties }) { @@ -572,7 +432,7 @@ class NCMedia: UIViewController { if NCNetworking.shared.fileExistsQueue.operationCount == 0, !ocIdDoNotExists.isEmpty, let ocIdDoNotExists = self.ocIdDoNotExists.getArray() { - dataSource.removeCompactMetadata(ocIdDoNotExists) + dataSource.removeMetadata(ocIdDoNotExists) database.deleteMetadataOcIds(ocIdDoNotExists) self.ocIdDoNotExists.removeAll() collectionViewReloadData() @@ -601,6 +461,7 @@ class NCMedia: UIViewController { case 0...1: pointSize = 60 case 2...3: pointSize = 30 case 4...5: pointSize = 25 + case 6...Int(maxColumns): pointSize = 20 default: pointSize = 20 } if let image = UIImage(systemName: "photo.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))?.withTintColor(.systemGray4, renderingMode: .alwaysOriginal) { @@ -611,37 +472,6 @@ class NCMedia: UIViewController { } } - @MainActor - func updateImageCacheWindow(force: Bool = false) { - guard !dataSource.compactMetadatas.isEmpty else { - return - } - - let visibleIndexPaths = collectionView.indexPathsForVisibleItems.sorted() - - let centerIndex: Int - - if !visibleIndexPaths.isEmpty { - let centerIndexPath = visibleIndexPaths[visibleIndexPaths.count / 2] - - guard let visibleCenterIndex = dataSource.globalIndex(for: centerIndexPath) else { - return - } - - centerIndex = visibleCenterIndex - } else { - centerIndex = 0 - } - - imageCache.updateImageCacheWindow( - imageCacheWindowItems: dataSource.imageCacheWindowItems, - centerIndex: centerIndex, - numberOfColumns: numberOfColumns, - session: session, - force: force - ) - } - // MARK: - Command func setupMediaCommandView() { @@ -685,6 +515,62 @@ class NCMedia: UIViewController { } } +// MARK: - + +extension NCMedia: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !dataSource.metadatas.isEmpty { + isTop = scrollView.contentOffset.y <= -(insetsTop + view.safeAreaInsets.top - 25) +// setTitleDate() + if lastContentOffsetY == 0 || lastContentOffsetY / 2 <= scrollView.contentOffset.y || lastContentOffsetY / 2 >= scrollView.contentOffset.y { + setTitleDate() + lastContentOffsetY = scrollView.contentOffset.y + } + setNeedsStatusBarAppearanceUpdate() + } + setElements() + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + if !decelerate { + searchNewMedia() + } + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + searchNewMedia() + } + + func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { + let y = view.safeAreaInsets.top + scrollView.contentOffset.y = -(insetsTop + y) + } +} + +// MARK: - + +extension NCMedia: NCSelectDelegate { + func dismissSelect(serverUrl: String?, metadata: tableMetadata?, type: String, items: [Any], overwrite: Bool, copy: Bool, move: Bool, session: NCSession.Session) { + guard let serverUrl else { return } + + Task { + let home = utilityFileSystem.getHomeServer(session: session) + let mediaPath = serverUrl.replacingOccurrences(of: home, with: "") + + await database.setAccountMediaPathAsync(mediaPath, account: session.account) + + imageCache.removeAll() + await loadDataSource() + searchNewMedia() + } + } +} + // MARK: - Media Command View class NCMediaCommandView: UIView { From ab4b186403c1d7249b734f291bb616a4338a2dce Mon Sep 17 00:00:00 2001 From: harshada-15-tsys Date: Mon, 7 Sep 2026 17:08:07 +0530 Subject: [PATCH 3/4] NMC - Rebasing album changes with NC 34.1.4 and fixing conflics Signed-off-by: harshada-15-tsys --- .../xcshareddata/swiftpm/Package.resolved | 384 +++++++++++++++ iOSClient/Analytics/AnalyticsHelper.swift | 90 ++++ iOSClient/Analytics/AnalyticsService.swift | 98 ++++ .../MoEngage/MoEngageAnalytics.swift | 441 ++++++++++++++++++ .../NCManageDatabase+CreateMetadata.swift | 67 +++ .../Data/NCManageDatabase+Metadata.swift | 31 ++ .../Extensions/UIApplication+Extension.swift | 7 + .../UINavigationController+Extension.swift | 34 ++ .../UIViewController+Extension.swift | 28 ++ iOSClient/Main/Main.storyboard | 111 +---- .../Main/NCMainNavigationController.swift | 1 + ...+CollectionViewDataSourcePrefetching.swift | 24 + iOSClient/Media/NCMedia.storyboard | 63 ++- iOSClient/Media/NCMedia.swift | 334 +++++++++---- iOSClient/Media/NCMediaDataSource.swift | 107 +++++ .../Media/NCMediaNavigationController.swift | 339 +++++++------- iOSClient/Media/NCMediaNetwork.swift | 47 ++ iOSClient/NCGlobal.swift | 28 ++ iOSClient/NCImageCache.swift | 17 + iOSClient/Utility/NCContentPresenter.swift | 332 +++++++++++++ iOSClient/Utility/NCUtility+Image.swift | 66 +++ iOSClient/Utility/NCUtilityFileSystem.swift | 47 ++ 22 files changed, 2311 insertions(+), 385 deletions(-) create mode 100644 Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 iOSClient/Analytics/AnalyticsHelper.swift create mode 100644 iOSClient/Analytics/AnalyticsService.swift create mode 100644 iOSClient/Analytics/MoEngage/MoEngageAnalytics.swift create mode 100644 iOSClient/Media/NCMedia+CollectionViewDataSourcePrefetching.swift create mode 100644 iOSClient/Utility/NCContentPresenter.swift diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..0275536fe7 --- /dev/null +++ b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,384 @@ +{ + "originHash" : "e17d49ec88ee53b0fdb113e0051b7e0397e08b69a7467353ebb39df35f8836e3", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire", + "state" : { + "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", + "version" : "5.12.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", + "version" : "11.3.1" + } + }, + { + "identity" : "codescanner", + "kind" : "remoteSourceControl", + "location" : "https://github.com/twostraws/CodeScanner.git", + "state" : { + "revision" : "5e886430238944c7200fc9e10dbf2d9550dba865", + "version" : "2.5.2" + } + }, + { + "identity" : "dropdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/AssistoLab/DropDown", + "state" : { + "branch" : "master", + "revision" : "2ab6f6ce19f0117d1a76ea043ef8f57722c65d16" + } + }, + { + "identity" : "easytipview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marinofaggiana/EasyTipView", + "state" : { + "branch" : "master", + "revision" : "bb25a13c8fa5d65144c8e5b5d31237b80a001b07" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "346daa9f46316aa372b35b317e18224acc2e9063", + "version" : "12.18.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", + "version" : "3.6.1" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "f04760d460296cc0fa430935a7be212e5bd67fc5", + "version" : "12.18.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", + "version" : "10.1.1" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", + "version" : "8.1.2" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", + "version" : "5.3.1" + } + }, + { + "identity" : "gzipswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/1024jp/GzipSwift", + "state" : { + "revision" : "99cf703b461916279cbbb50068e904df86c37133", + "version" : "6.0.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "keychainaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/KeychainAccess", + "state" : { + "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", + "version" : "4.2.2" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "lucidbanner", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marinofaggiana/LucidBanner", + "state" : { + "revision" : "d7ea11cac476f32f63e0ad60f393bc7937b1d019", + "version" : "0.7.1" + } + }, + { + "identity" : "mantis", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marinofaggiana/Mantis", + "state" : { + "revision" : "d145ce2403e0d94d4926eb8510bc4966fbf1bfca", + "version" : "1.0.0" + } + }, + { + "identity" : "markdownkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/bmoliveira/MarkdownKit", + "state" : { + "revision" : "39ea7a3fc7e65bcafe55152592b834859be574a5", + "version" : "1.7.3" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, + { + "identity" : "nextcloudkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nextcloud/NextcloudKit", + "state" : { + "revision" : "2c86f8b3af59b51f60bfcc8620f59f845a5554fb", + "version" : "7.5.0" + } + }, + { + "identity" : "openssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/OpenSSL", + "state" : { + "revision" : "4b1188ba947ddc23d03b13096b9e44b804e146c9", + "version" : "3.6.3000" + } + }, + { + "identity" : "popupview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/PopupView.git", + "state" : { + "revision" : "62e1698164c352e4cae074bb30eff011ef1b9af6", + "version" : "5.0.5" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + }, + { + "identity" : "qrcodereader.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/yannickl/QRCodeReader.swift", + "state" : { + "revision" : "5020b5a47199d8ba80c83a4b4fafd70e9dc9dc7f", + "version" : "10.1.1" + } + }, + { + "identity" : "queuer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/FabrizioBrancati/Queuer", + "state" : { + "revision" : "9ae00b0da125f127f1bbf2e92d8e1fbb0994427e", + "version" : "4.0.0" + } + }, + { + "identity" : "realm-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/realm/realm-core.git", + "state" : { + "revision" : "b4192c46305570577c4d08df790fddec5ab3aa04", + "version" : "20.1.5" + } + }, + { + "identity" : "realm-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/realm/realm-swift", + "state" : { + "revision" : "ca03df491ec5e4bb8af0bca1e9957d08c10ec2da", + "version" : "20.0.5" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl", + "state" : { + "revision" : "d930168b86f46ca51a4bc09c5ca45c1833db8067", + "version" : "2.37.2" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + }, + { + "identity" : "swiftentrykit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huri000/SwiftEntryKit", + "state" : { + "revision" : "5ad36cccf0c4b9fea32f4e9b17a8e38f07563ef0", + "version" : "2.0.0" + } + }, + { + "identity" : "swifter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/httpswift/swifter.git", + "state" : { + "branch" : "stable", + "revision" : "1e4f51c92d7ca486242d8bf0722b99de2c3531aa" + } + }, + { + "identity" : "swiftrichstring", + "kind" : "remoteSourceControl", + "location" : "https://github.com/malcommac/SwiftRichString", + "state" : { + "revision" : "9bf4b5af6bb4386865636fc504d6c588c2b65040", + "version" : "3.7.2" + } + }, + { + "identity" : "swiftyjson", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SwiftyJSON/SwiftyJSON", + "state" : { + "revision" : "af76cf3ef710b6ca5f8c05f3a31307d44a3c5828", + "version" : "5.0.2" + } + }, + { + "identity" : "swiftyxmlparser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/yahoojapan/SwiftyXMLParser", + "state" : { + "revision" : "d7a1d23f04c86c1cd2e8f19247dd15d74e0ea8be", + "version" : "5.6.0" + } + }, + { + "identity" : "taglistview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ElaWorkshop/TagListView", + "state" : { + "revision" : "b30b61826736f4d9ec04aef5df891f3914423f16", + "version" : "1.4.1" + } + }, + { + "identity" : "tlphotopicker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tilltue/TLPhotoPicker", + "state" : { + "revision" : "91d7bca65721928af30c6a2bb1a1f517f7862964", + "version" : "2.1.21" + } + }, + { + "identity" : "vlckit-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tylerjonesio/vlckit-spm", + "state" : { + "revision" : "e932bbd488872fdb74f6654d28c2f291eae03daf", + "version" : "3.6.0" + } + } + ], + "version" : 3 +} diff --git a/iOSClient/Analytics/AnalyticsHelper.swift b/iOSClient/Analytics/AnalyticsHelper.swift new file mode 100644 index 0000000000..3b765cd06a --- /dev/null +++ b/iOSClient/Analytics/AnalyticsHelper.swift @@ -0,0 +1,90 @@ +// +// AnalyticsHelper.swift +// Nextcloud +// +// Created by Amrut Waghmare on 10/06/24. +// Copyright © 2024 Marino Faggiana. All rights reserved. +// + +import Foundation + +class AnalyticsHelper: NSObject, AnalyticsService { + + @objc static let shared = AnalyticsHelper() + + private var analyticsServices: [AnalyticsService] + + private override init() { + // Initialize the analytics services + let moEngageAnalytics = MoEngageAnalytics() + moEngageAnalytics.trackAppId() + analyticsServices = [moEngageAnalytics] + super.init() + } + + func trackEvent(eventName: AnalyticEvents, properties: [String: Any]? = nil) { + DispatchQueue.global(qos: .background).async { + self.analyticsServices.forEach { $0.trackEvent(eventName: eventName, properties: properties) } + } + } + + func trackUserData() { + DispatchQueue.global(qos: .background).async { + self.analyticsServices.forEach { $0.trackUserData() } + } + } + + func trackUsedStorageData(quotaUsed: Int64) { + DispatchQueue.global(qos: .background).async { + self.analyticsServices.forEach { $0.trackUsedStorageData(quotaUsed: quotaUsed) } + } + } + + @objc func trackAutoUpload(isEnable: Bool) { + DispatchQueue.global(qos: .background).async { + self.analyticsServices.forEach { $0.trackAutoUpload(isEnable: isEnable) } + } + } + + func trackAppVersion(oldVersion: String?) { + DispatchQueue.global(qos: .background).async { + self.analyticsServices.forEach { $0.trackAppVersion(oldVersion: oldVersion) } + } + } + + func trackLogout() { + DispatchQueue.global(qos: .background).async { + self.analyticsServices.forEach { $0.trackLogout() } + } + } + + func trackCreateFile(metadata: tableMetadata) { + DispatchQueue.global(qos: .background).async { + self.analyticsServices.forEach { $0.trackCreateFile(metadata: metadata) } + } + } + + func trackEventWithMetadata(eventName: AnalyticEvents, metadata: tableMetadata) { + DispatchQueue.global(qos: .background).async { + self.analyticsServices.forEach { $0.trackEventWithMetadata(eventName: eventName, metadata: metadata) } + } + } + + func trackCreateVoiceMemo(size: Int64, date: Date) { + DispatchQueue.global(qos: .background).async { + self.analyticsServices.forEach { $0.trackCreateVoiceMemo(size: size, date: date) } + } + } + + func trackCreateFolder(isEncrypted: Bool, creationDate: Date) { + DispatchQueue.global(qos: .background).async { + self.analyticsServices.forEach { $0.trackCreateFolder(isEncrypted: isEncrypted, creationDate: creationDate) } + } + } + +// func displayInAppNotification() { +// self.analyticsServices.forEach { $0.displayInAppNotification() } +// } + + +} diff --git a/iOSClient/Analytics/AnalyticsService.swift b/iOSClient/Analytics/AnalyticsService.swift new file mode 100644 index 0000000000..7da2e93d45 --- /dev/null +++ b/iOSClient/Analytics/AnalyticsService.swift @@ -0,0 +1,98 @@ +// +// AnalyticsService.swift +// Nextcloud +// +// Created by Amrut Waghmare on 10/06/24. +// Copyright © 2024 Marino Faggiana. All rights reserved. +// + +import Foundation + +protocol AnalyticsService { + func trackEvent(eventName: AnalyticEvents, properties: [String: Any]?) + func trackUserData() + func trackUsedStorageData(quotaUsed: Int64) + func trackAutoUpload(isEnable: Bool) + func trackAppVersion(oldVersion: String?) + func trackLogout() + func trackCreateFile(metadata: tableMetadata) + func trackCreateVoiceMemo(size: Int64, date: Date) + func trackCreateFolder(isEncrypted: Bool, creationDate: Date) + func trackEventWithMetadata(eventName: AnalyticEvents, metadata: tableMetadata) +// func displayInAppNotification() +} + +// swiftlint:disable identifier_name +enum AnalyticEvents: String { + case USER_PROPERTIES_STORAGE_CAPACITY = "storage_capacity" // in GB + case USER_PROPERTIES_STORAGE_USED = "storage_used" // % of storage used + case USER_PROPERTIES_AUTO_UPLOAD = "auto_upload_on" + case USER_PROPERTIES_APP_VERSION = "app_version" + case EVENT__ACTION_BUTTON = "action_button_clicked" // when user clicks on fab + button + case EVENT__UPLOAD_FILE = "upload_file" // when user uploads any file (not applicable for folder) from other apps + case EVENT__CREATE_FILE = "create_file" // when user creates any file in app + case EVENT__CREATE_FOLDER = "create_folder" + case EVENT__CREATE_VOICE_MEMO = "create_voice_memo" + case EVENT__ADD_FAVORITE = "add_favorite" + case EVENT__SHARE_FILE = "share_file" // when user share any file using link + case EVENT__OFFLINE_AVAILABLE = "offline_available" + case EVENT__ONLINE_OFFICE_USED = "online_office_used" // when user opens any office files + + // screen view events when user open specific screen + case SCREEN_EVENT__FAVOURITES = "favorites" + case SCREEN_EVENT__MEDIA = "medien" + case SCREEN_EVENT__OFFLINE_FILES = "offline_files" + case SCREEN_EVENT__SHARED = "shared" + case SCREEN_EVENT__DELETED_FILES = "deleted_files" + case SCREEN_EVENT__NOTIFICATIONS = "notifications" + + var moEngageEvent: String { + switch self { + default: + return self.rawValue + } + } + + var teliumEvent: String { + return self.rawValue + } + + var adjustEvent: String { + return self.rawValue + } +} + +// swiftlint:disable identifier_name +enum AnalyticPropertyAttributes: String { + // properties attributes key + case PROPERTIES__FILE_TYPE = "file_type" + case PROPERTIES__FOLDER_TYPE = "folder_type" + case PROPERTIES__FILE_SIZE = "file_size" // in MB + case PROPERTIES__CREATION_DATE = "creation_date" // yyyy-MM-dd + case PROPERTIES__UPLOAD_DATE = "upload_date" // // yyyy-MM-dd +} + +enum FolderType: String { + // properties attributes key + case FOLDER_ENCRYPTED = "encrypted" + case FOLDER_NORMAL = "not encrypted" +} + + +enum Size { + static let KILOBYTE = 1024 + static let MEGABYTE = KILOBYTE * 1024 + static let GIGABYTE = MEGABYTE * 1024 +} + +enum FileType: String { + case FOTO = "foto" + case AUDIO = "audio" + case VIDEO = "video" + case PDF = "pdf" + case TEXT = "text" + case DOCX = "docx" + case XLSX = "xlsx" + case PPT = "ppt" + case OTHER = "other" +} diff --git a/iOSClient/Analytics/MoEngage/MoEngageAnalytics.swift b/iOSClient/Analytics/MoEngage/MoEngageAnalytics.swift new file mode 100644 index 0000000000..c6fdbf101d --- /dev/null +++ b/iOSClient/Analytics/MoEngage/MoEngageAnalytics.swift @@ -0,0 +1,441 @@ +// +// MoEngageAnalytics.swift +// Nextcloud +// +// Created by Amrut Waghmare on 10/06/24. +// Copyright © 2024 Marino Faggiana. All rights reserved. +// + +import Foundation +import MoEngageSDK +import MoEngageInApps +import StoreKit +import OSLog + +class MoEngageAnalytics: NSObject { + static let shared = MoEngageAnalytics() + private let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "MoEngage", category: "MoEngageAnalytics") + + // Initializer for the MoEngageAnalytics class + override init() { + super.init() + + log.debug("Initializing MoEngage SDK…") + + // Create a configuration object for MoEngage SDK with the given App ID and Data Center + let sdkConfig = MoEngageSDKConfig(appId: "7KWWUKA6OKXGP8Q6DMCXLDX5", dataCenter: MoEngageDataCenter.data_center_02) + + // Disable periodic flushing of analytics data + sdkConfig.analyticsDisablePeriodicFlush = true + + // Initialize the MoEngage SDK + // Use different initialization methods for Debug and Production environments + +#if DEBUG + MoEngage.sharedInstance.initializeDefaultTestInstance(sdkConfig) +#else + MoEngage.sharedInstance.initializeDefaultLiveInstance(sdkConfig) +#endif + log.debug("MoEngage SDK initializeDefault instance set (debug: \( self._isDebugBuild() ))") + + Task { @MainActor in + setupMoEngageInAppMessaging() + } + + // Register delegate for In-App Native callbacks + MoEngageSDKInApp.sharedInstance.setInAppDelegate(self) + log.debug("MoEngage In-App delegate set") + } + + // MARK: - Setup Helper + /// Call this early in app lifecycle (e.g., AppDelegate didFinishLaunching or SceneDelegate willConnect) + static func setupIfNeeded() { + _ = MoEngageAnalytics.shared + } + + // MARK: - UI Helpers + private func activeWindowScene() -> UIWindowScene? { + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + } + + private func topViewController(in root: UIViewController?) -> UIViewController? { + guard let root = root else { return nil } + if let nav = root as? UINavigationController { return topViewController(in: nav.visibleViewController) } + if let tab = root as? UITabBarController { return topViewController(in: tab.selectedViewController) } + if let presented = root.presentedViewController { return topViewController(in: presented) } + return root + } + + @MainActor + func currentTopViewController() -> UIViewController? { + // 1. Find the active window scene (Crucial for iPad) + let activeScene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + + // 2. Get the key window from that specific scene + let keyWindow = activeScene?.windows.first { $0.isKeyWindow } + ?? activeScene?.windows.first + + // 3. Start from the rootViewController + var topController = keyWindow?.rootViewController + + // 4. Drill down through navigation, tabs, and presented controllers + while let presentedController = topController?.presentedViewController { + topController = presentedController + } + + return topController + } + + /// Safer wrapper that ensures visible scene and top VC before asking MoEngage to show in-apps + @MainActor + func displayInAppNotificationSafely(reason: String? = nil) { + let reasonText = reason ?? "unspecified" + + // 1. Get all connected window scenes + let scenes = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + + // 2. Prioritize the scene that actually has the "Key Window" (Works best for iPad Split View) + let activeScene = scenes.first { $0.windows.contains(where: { $0.isKeyWindow }) } + ?? scenes.first { $0.activationState == .foregroundActive } + + guard let scene = activeScene else { + print("In-App not shown: No foreground window scene (reason: \(reasonText))") + return + } + + // 3. Ensure we have a valid top controller + guard let topVC = currentTopViewController() else { + print("In-App not shown: No top view controller available (reason: \(reasonText))") + return + } + + // 4. Trigger MoEngage + // iPad Tip: MoEngage uses the key window to determine where to draw the UI + if UIDevice.current.userInterfaceIdiom != .pad{ + self.triggerMoEngage() + } + else { +//#if targetEnvironment(simulator) +// // Simulator: Always show fallback alert +// print("In-App shown: showInApp)") +// let appID = "312838242" +// +// // Correct URLs with /app/id/ prefix +// let webURLString = "https://apps.apple.com\(appID)?action=write-review" +// +// self.showSimulatorAlert(link: webURLString) +//#else + // iPad Fallback: Scene might be transitioning. Retry once. + print("iPad Scene transition detected. Retrying...") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.triggerMoEngage() + } +//#endif + } + } + + private func triggerMoEngage() { + MoEngageSDKInApp.sharedInstance.showInApp() + MoEngageSDKInApp.sharedInstance.showNudge() + } + + + private func _isDebugBuild() -> Bool { + #if DEBUG + return true + #else + return false + #endif + } + + // Method to track the App ID + func trackAppId() { + MoEngageSDKAnalytics.sharedInstance.trackLocale(forAppID: "312838242") + } + + @MainActor func setupMoEngageInAppMessaging() { + // MARK: MoEngage In-App messages + log.debug("setupMoEngageInAppMessaging() — scheduling initial in-app evaluation") + Task { @MainActor in + displayInAppNotificationSafely(reason: "initial setup") + } + } + + // Helper to show the alert on the topmost view controller + private func showSimulatorAlert(link: String) { + let alert = UIAlertController( + title: "Simulator Mode", + message: "App Store links don't open in Simulator. On a real device, this would open: \(link)", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + + // Finds the current active window to present the alert + let keyWindow = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + + keyWindow?.rootViewController?.present(alert, animated: true) + } + +} + +// AnalyticsService protocol +extension MoEngageAnalytics: AnalyticsService { + // Method to track a specific event with optional properties + func trackEvent(eventName: AnalyticEvents, properties: [String: Any]?) { + let eventProperties = MoEngageProperties(withAttributes: properties) + MoEngageSDKAnalytics.sharedInstance.trackEvent(eventName.moEngageEvent, withProperties: eventProperties) + Task { @MainActor in + // Re-evaluate in-app messages after trackEvent + MoEngageAnalytics.shared.displayInAppNotificationSafely(reason: "track Event") + } + + } + + // Method to track user data + func trackUserData() { + // Get the active user account from the database + guard let user = NCManageDatabase.shared.getActiveTableAccount() else { return } + + // Set user attributes in the MoEngage SDK + MoEngageSDKAnalytics.sharedInstance.setUniqueID(user.userId) + MoEngageSDKAnalytics.sharedInstance.setName(user.displayName) + MoEngageSDKAnalytics.sharedInstance.setEmailID(user.email) + + // Convert the user's total storage quota to a readable format and set it as a user attribute + let storageCapacity = NCUtilityFileSystem().transformedSize(user.quotaTotal) + MoEngageSDKAnalytics.sharedInstance.setUserAttribute(storageCapacity, withAttributeName: AnalyticEvents.USER_PROPERTIES_STORAGE_CAPACITY.rawValue) + + // Track whether auto-upload is enabled for the user + trackAutoUpload(isEnable: user.autoUploadStart) + } + + // Method to track the used storage data + func trackUsedStorageData(quotaUsed: Int64) { + MoEngageSDKAnalytics.sharedInstance.setUserAttribute(quotaUsed, withAttributeName: AnalyticEvents.USER_PROPERTIES_STORAGE_USED.rawValue) + } + + // Method to track the auto-upload setting + func trackAutoUpload(isEnable: Bool) { + if isEnable { + MoEngageSDKAnalytics.sharedInstance.setUserAttribute(isEnable, withAttributeName: AnalyticEvents.USER_PROPERTIES_AUTO_UPLOAD.rawValue) + } + } + + // Method to track the app version + func trackAppVersion(oldVersion: String?) { + // Get the app version and set it as a user attribute + let version = NCUtility().getVersionBuild() as String + + // Check if a build version key is present in UserDefaults + if let oldVersion { + if version != oldVersion { + MoEngageSDKAnalytics.sharedInstance.appStatus(.update) + if let oldAppVersion = Int(oldVersion.dropLast().replacingOccurrences(of: ".", with: "")) { + if oldAppVersion < NCGlobal.shared.moEngageAppVersion { + trackUserData() + } + } + } + } else { + MoEngageSDKAnalytics.sharedInstance.appStatus(.install) + } + + MoEngageSDKAnalytics.sharedInstance.setUserAttribute(version, withAttributeName: AnalyticEvents.USER_PROPERTIES_APP_VERSION.rawValue) + } + + //Method to track user logout + func trackLogout() { + MoEngageSDKAnalytics.sharedInstance.resetUser() + } + + //Method to track create file + func trackCreateFile(metadata: tableMetadata) { + let properties = MoEngageProperties() + properties.addAttribute(getFileType(contentType: metadata.contentType), withName: AnalyticPropertyAttributes.PROPERTIES__FILE_TYPE.rawValue) + properties.addAttribute(String(getFileSizeInMB(bytes: Int(metadata.size))), withName: AnalyticPropertyAttributes.PROPERTIES__FILE_SIZE.rawValue) + properties.addAttribute(getDate(date: metadata.creationDate as Date), withName: AnalyticPropertyAttributes.PROPERTIES__CREATION_DATE.rawValue) + MoEngageSDKAnalytics.sharedInstance.trackEvent(AnalyticEvents.EVENT__CREATE_FILE.rawValue, withProperties: properties) + } + + //Method to track upload file + func trackEventWithMetadata(eventName: AnalyticEvents, metadata: tableMetadata) { + let properties = MoEngageProperties() + properties.addAttribute(getFileType(contentType: metadata.contentType), withName: AnalyticPropertyAttributes.PROPERTIES__FILE_TYPE.rawValue) + properties.addAttribute(String(getFileSizeInMB(bytes: Int(metadata.size))), withName: AnalyticPropertyAttributes.PROPERTIES__FILE_SIZE.rawValue) + properties.addAttribute(getDate(date: metadata.creationDate as Date), withName: AnalyticPropertyAttributes.PROPERTIES__CREATION_DATE.rawValue) + properties.addAttribute(getDate(date: metadata.uploadDate as Date), withName: AnalyticPropertyAttributes.PROPERTIES__UPLOAD_DATE.rawValue) + MoEngageSDKAnalytics.sharedInstance.trackEvent(eventName.rawValue, withProperties: properties) + } + + //Method to track create folder + func trackCreateFolder(isEncrypted: Bool, creationDate: Date) { + let properties = MoEngageProperties() + properties.addAttribute(isEncrypted ? FolderType.FOLDER_ENCRYPTED.rawValue : FolderType.FOLDER_NORMAL.rawValue , withName: AnalyticPropertyAttributes.PROPERTIES__FILE_TYPE.rawValue) + properties.addAttribute(getDate(date: creationDate), withName: AnalyticPropertyAttributes.PROPERTIES__CREATION_DATE.rawValue) + MoEngageSDKAnalytics.sharedInstance.trackEvent(AnalyticEvents.EVENT__CREATE_FOLDER.rawValue, withProperties: properties) + } + + //Method to track create voice memo + func trackCreateVoiceMemo(size: Int64, date: Date) { + let properties = MoEngageProperties() + properties.addAttribute(FileType.AUDIO.rawValue, withName: AnalyticPropertyAttributes.PROPERTIES__FILE_TYPE.rawValue) + properties.addAttribute(String(getFileSizeInMB(bytes: Int(size))), withName: AnalyticPropertyAttributes.PROPERTIES__FILE_SIZE.rawValue) + properties.addAttribute(getDate(date: date), withName: AnalyticPropertyAttributes.PROPERTIES__CREATION_DATE.rawValue) + MoEngageSDKAnalytics.sharedInstance.trackEvent(AnalyticEvents.EVENT__CREATE_VOICE_MEMO.rawValue, withProperties: properties) + } +} + +// Functions +extension MoEngageAnalytics { + private func getFileType(contentType: String) -> String? { + switch contentType { + case "image/png": + return FileType.FOTO.rawValue + case "audio/x-m4a", "audio/mp4": + return FileType.AUDIO.rawValue + case "video/mp4": + return FileType.VIDEO.rawValue + case "application/pdf": + return FileType.PDF.rawValue + case "text/x-markdown","text/plain": + return FileType.TEXT.rawValue + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return FileType.DOCX.rawValue + case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet","text/csv": + return FileType.XLSX.rawValue + case "application/vnd.openxmlformats-officedocument.presentationml.presentation": + return FileType.PPT.rawValue + default: + return FileType.OTHER.rawValue + } + } + + private func getDate(date: Date) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + return dateFormatter.string(from: date) + } + + private func getFileSizeInMB(bytes: Int) -> Float { + return round((Float(bytes) / Float(Size.MEGABYTE)) * 10) / 10 + } + +} + +// MARK: - MoEngage In-App Native Delegate +extension MoEngageAnalytics: MoEngageInAppNativeDelegate { + + // Called when user clicks an in-app with navigation action (e.g., deep link) + func inAppClicked(withCampaignInfo inappCampaign: MoEngageInAppCampaign, + andNavigationActionInfo navigationAction: MoEngageInAppNavigationAction, + forAccountMeta accountMeta: MoEngageAccountMeta) { + // handle navigation actions if needed + } + + // Called when user clicks an in-app with custom action (e.g., our rating trigger) + func inAppClicked(withCampaignInfo inappCampaign: MoEngageInAppCampaign, + andCustomActionInfo customAction: MoEngageInAppAction, + forAccountMeta accountMeta: MoEngageAccountMeta) { + + let kv = customAction.keyValuePairs + log.debug("In-App custom action received: keyValues=\(kv)") + + if let showRating = kv["show-native-rating"] as? String, + showRating.lowercased() == "true" { + log.debug("User clicked Rating - Opening App Store directly") + + // Use the reliable direct link instead of the restricted native prompt + ReviewManager.shared.requestReviewIfAppropriate() + } + } + + // Called when a "self-handled" in-app is triggered + func selfHandledInAppTriggered(withInfo inAppCampaign: MoEngageInAppSelfHandledCampaign, + forAccountMeta accountMeta: MoEngageAccountMeta) { + log.debug("Self-handled in-app triggered: \(String(describing: inAppCampaign))") + } + + // Optional — track impression + func inAppShown(withCampaignInfo inappCampaign: MoEngageInAppCampaign, + forAccountMeta accountMeta: MoEngageAccountMeta) { + log.debug("In-App shown: \(String(describing: inappCampaign))") + } + + // Optional — track dismissal + func inAppDismissed(withCampaignInfo inappCampaign: MoEngageInAppCampaign, + forAccountMeta accountMeta: MoEngageAccountMeta) { + log.debug("In-App dismissed: \(String(describing: inappCampaign))") + } +} + +@MainActor // Ensures this is only called on the Main Thread +class UIHelper { + + static func getTopViewController() -> UIViewController? { + // 1. Get the active scene + let activeScene = UIApplication.shared.connectedScenes + .first { $0.activationState == .foregroundActive } as? UIWindowScene + + // 2. Get the root view controller from the key window + let rootVC = activeScene?.windows + .first { $0.isKeyWindow }?.rootViewController + + return findTopViewController(from: rootVC) + } + + private static func findTopViewController(from root: UIViewController?) -> UIViewController? { + if let nav = root as? UINavigationController { + return findTopViewController(from: nav.visibleViewController) + } + if let tab = root as? UITabBarController { + return findTopViewController(from: tab.selectedViewController) + } + if let presented = root?.presentedViewController { + return findTopViewController(from: presented) + } + return root + } +} + +class ReviewManager { + static let shared = ReviewManager() + private let appID = "312838242" + + /// Requests the native system rating sheet if an active foreground scene is available. + func requestReviewIfAppropriate() { + // 1. Find the active, foreground UIWindowScene required by StoreKit + guard let scene = UIApplication.shared.connectedScenes + .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene else { + // Silently exit if no foreground view is active +// // Fallback: If no scene is active (rare), open the App Store directly +// self.forceOpenAppStore() + return + } + + // 2. Trigger the native system prompt overlay + // Note: Apple will throttle this to 3 times per year maximum per user. + SKStoreReviewController.requestReview(in: scene) + } + + /// Call this function ONLY when a user explicitly taps a manual "Rate Us" button. + func forceOpenAppStore() { + let appStoreURLString = "itms-apps://://apple.com\(appID)?action=write-review" + let webURLString = "https://apple.com\(appID)?action=write-review" + + if let url = URL(string: appStoreURLString), UIApplication.shared.canOpenURL(url) { + // Opens the native iOS App Store app directly to your review composition page + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } else if let fallbackWebUrl = URL(string: webURLString) { + // Fallback safety layer: Opens up your App Store page inside Safari + UIApplication.shared.open(fallbackWebUrl, options: [:], completionHandler: nil) + } + } +} diff --git a/iOSClient/Data/NCManageDatabase+CreateMetadata.swift b/iOSClient/Data/NCManageDatabase+CreateMetadata.swift index 7fb5032a49..0b4cf5c50e 100644 --- a/iOSClient/Data/NCManageDatabase+CreateMetadata.swift +++ b/iOSClient/Data/NCManageDatabase+CreateMetadata.swift @@ -104,6 +104,73 @@ final class NCManageDatabaseCreateMetadata { } return (metadataFolder.detachedCopy(), metadatas) } + + func convertFilesToMetadatasAsync(_ files: [NKFile], serverUrlMetadataFolder: String? = nil, mediaSearch: Bool = false) async -> (metadataFolder: tableMetadata, metadatas: [tableMetadata]) { + var counter: Int = 0 + var isDirectoryE2EE: Bool = false + var listServerUrl: [String: Bool] = [:] + var metadataFolder = tableMetadata() + var metadatas: [tableMetadata] = [] + + for file in files { +#if !EXTENSION_FILE_PROVIDER_EXTENSION + if let key = listServerUrl[file.serverUrl] { + isDirectoryE2EE = key + } else { + isDirectoryE2EE = NCUtilityFileSystem().isDirectoryE2EE(serverUrl: file.serverUrl, urlBase: file.urlBase, userId: file.userId, account: file.account) + listServerUrl[file.serverUrl] = isDirectoryE2EE + } +#endif + + let metadata = await convertFileToMetadataAsync(file, mediaSearch: mediaSearch, isDirectoryE2EE: isDirectoryE2EE) + + if serverUrlMetadataFolder == metadata.serverUrlFileName || metadata.fileName == NextcloudKit.shared.nkCommonInstance.rootFileName { + metadataFolder = metadata + } else { + metadatas.append(metadata) + } + + counter += 1 + } + return (metadataFolder.detachedCopy(), metadatas) + } + + func convertFileToMetadataAsync(_ file: NKFile, mediaSearch: Bool = false, isDirectoryE2EE: Bool? = nil) async -> tableMetadata { + let metadata = self.createMetadata(file) + let e2eEncryptedDirectory: Bool + if let value = isDirectoryE2EE { + e2eEncryptedDirectory = value + } else { + e2eEncryptedDirectory = await NCUtilityFileSystem().isDirectoryE2EEAsync( + serverUrl: file.serverUrl, + urlBase: file.urlBase, + userId: file.userId, + account: file.account) + } + +#if !EXTENSION_FILE_PROVIDER_EXTENSION + // E2EE find the fileName for fileNameView + if e2eEncryptedDirectory || file.e2eEncrypted { + if let tableE2eEncryption = await NCManageDatabase.shared.getE2eEncryptionAsync(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@ AND fileNameIdentifier == %@", file.account, file.serverUrl, file.fileName)) { + metadata.fileNameView = tableE2eEncryption.fileName + } else if e2eEncryptedDirectory { + metadata.fileNameView = NSLocalizedString("_e2e_file_encrypted_", comment: "") + } + } +#endif + + if !metadata.directory { + let results = await NKTypeIdentifiers.shared.getInternalType(fileName: metadata.fileNameView, mimeType: file.contentType, directory: file.directory, account: file.account) + + metadata.contentType = results.mimeType + metadata.iconName = results.iconName + metadata.classFile = results.classFile + metadata.typeIdentifier = results.typeIdentifier + metadata.mediaSearch = mediaSearch + } + + return metadata.detachedCopy() + } #if !EXTENSION_FILE_PROVIDER_EXTENSION && !EXTENSION_BACKGROUNDUPLOAD func convertFilesToMetadatas(_ files: [NKFile], capabilities: NKCapabilities.Capabilities?, serverUrlMetadataFolder: String? = nil, completion: @escaping (_ metadataFolder: tableMetadata?, _ metadatas: [tableMetadata]) -> Void) { diff --git a/iOSClient/Data/NCManageDatabase+Metadata.swift b/iOSClient/Data/NCManageDatabase+Metadata.swift index 58f63064bf..32bffa87d4 100644 --- a/iOSClient/Data/NCManageDatabase+Metadata.swift +++ b/iOSClient/Data/NCManageDatabase+Metadata.swift @@ -86,6 +86,7 @@ class tableMetadata: Object { @objc public var lockOwnerDisplayName = "" @objc public var lockTime: Date? @objc public var lockTimeOut: Date? + @objc dynamic var mediaSearch: Bool = false @objc dynamic var placeholder: Bool = false @objc dynamic var path = "" @objc dynamic var permissions = "" @@ -963,6 +964,36 @@ extension NCManageDatabase { ) } + /// Syncs the remote and local metadata. + /// Returns true if there were changes (additions or deletions), false if everything was already up-to-date. + func mergeRemoteMetadatasAsync(remoteMetadatas: [tableMetadata], localMetadatas: [tableMetadata]) async -> Bool { + // Set of ocId + let remoteOcIds = Set(remoteMetadatas.map { $0.ocId }) + let localOcIds = Set(localMetadatas.map { $0.ocId }) + + // Calculate diffs + let toDeleteOcIds = localOcIds.subtracting(remoteOcIds) + let toAddOcIds = remoteOcIds.subtracting(localOcIds) + + guard !toDeleteOcIds.isEmpty || !toAddOcIds.isEmpty else { + return false // No changes needed + } + + let toDeleteKeys = Array(toDeleteOcIds) + + await core.performRealmWriteAsync { realm in + let toAdd = remoteMetadatas.filter { toAddOcIds.contains($0.ocId) } + let toDelete = toDeleteKeys.compactMap { + realm.object(ofType: tableMetadata.self, forPrimaryKey: $0) + } + + realm.delete(toDelete) + realm.add(toAdd, update: .modified) + } + + return true + } + // MARK: - Realm Read func getAllTableMetadataAsync() async -> [tableMetadata] { diff --git a/iOSClient/Extensions/UIApplication+Extension.swift b/iOSClient/Extensions/UIApplication+Extension.swift index 177fe15aec..bec8868a93 100644 --- a/iOSClient/Extensions/UIApplication+Extension.swift +++ b/iOSClient/Extensions/UIApplication+Extension.swift @@ -55,4 +55,11 @@ extension UIApplication { .compactMap { $0 as? UIWindowScene } .filter { $0.activationState == .foregroundActive } } + + var firstWindow: UIWindow? { + let windowScenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let firstActiveScene = windowScenes.first + let keyWindow = firstActiveScene?.keyWindow + return keyWindow + } } diff --git a/iOSClient/Extensions/UINavigationController+Extension.swift b/iOSClient/Extensions/UINavigationController+Extension.swift index 46b9a063fb..8498338e3f 100644 --- a/iOSClient/Extensions/UINavigationController+Extension.swift +++ b/iOSClient/Extensions/UINavigationController+Extension.swift @@ -37,4 +37,38 @@ extension UINavigationController { navigationBar.tintColor = textColor navigationBar.prefersLargeTitles = false } + + func setMediaAppreance() { + setNavigationBarHidden(true, animated: false) + } + + func popupFromNavigationStack(context: String) { + guard let nav = currentNavigationController() else { + return + } + nav.popViewController(animated: true) + } + + func currentNavigationController() -> UINavigationController? { + // Try to find the key window's root and traverse to the visible navigation controller + let keyWindow = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + + guard let root = keyWindow?.rootViewController else { return nil } + return findNavigationController(from: root) + } + + func findNavigationController(from vc: UIViewController) -> UINavigationController? { + if let nav = vc as? UINavigationController { return nav } + if let tab = vc as? UITabBarController { + if let selected = tab.selectedViewController, let nav = findNavigationController(from: selected) { return nav } + } + if let presented = vc.presentedViewController, let nav = findNavigationController(from: presented) { return nav } + for child in vc.children { + if let nav = findNavigationController(from: child) { return nav } + } + return vc.navigationController + } } diff --git a/iOSClient/Extensions/UIViewController+Extension.swift b/iOSClient/Extensions/UIViewController+Extension.swift index 6c417b9f43..8bfca73703 100644 --- a/iOSClient/Extensions/UIViewController+Extension.swift +++ b/iOSClient/Extensions/UIViewController+Extension.swift @@ -6,6 +6,7 @@ import Foundation import UIKit import MessageUI import LucidBanner +import SwiftUI extension UIViewController { // https://stackoverflow.com/questions/6131205/how-to-find-topmost-view-controller-on-ios @@ -44,6 +45,33 @@ extension UIViewController { return false } } + + private struct Holder { + static var loader: UIHostingController? + } + + func showLoader() { + guard Holder.loader == nil else { return } + + let loader = UIHostingController(rootView: NCLoadingAlert()) + loader.view.frame = view.bounds + loader.view.backgroundColor = .clear + loader.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + addChild(loader) + view.addSubview(loader.view) + loader.didMove(toParent: self) + + Holder.loader = loader + } + + func hideLoader() { + guard let loader = Holder.loader else { return } + loader.willMove(toParent: nil) + loader.view.removeFromSuperview() + loader.removeFromParent() + Holder.loader = nil + } } extension UIViewController: @retroactive MFMailComposeViewControllerDelegate { diff --git a/iOSClient/Main/Main.storyboard b/iOSClient/Main/Main.storyboard index 92a1bd05dc..2858bd33a5 100644 --- a/iOSClient/Main/Main.storyboard +++ b/iOSClient/Main/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -24,7 +24,6 @@ - @@ -145,112 +144,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOSClient/Main/NCMainNavigationController.swift b/iOSClient/Main/NCMainNavigationController.swift index 915f9fe319..92d6574e55 100644 --- a/iOSClient/Main/NCMainNavigationController.swift +++ b/iOSClient/Main/NCMainNavigationController.swift @@ -295,6 +295,7 @@ class NCMainNavigationController: UINavigationController, UINavigationController guard !(collectionViewCommon?.isEditMode ?? false), !(trashViewController?.isEditMode ?? false), !(mediaViewController?.isEditMode ?? false), +// !(topViewController is NCViewerMediaPage), !(topViewController is NCViewerPDF), !(topViewController is NCViewerRichdocuments), !(topViewController is NCViewerDirectEditing) diff --git a/iOSClient/Media/NCMedia+CollectionViewDataSourcePrefetching.swift b/iOSClient/Media/NCMedia+CollectionViewDataSourcePrefetching.swift new file mode 100644 index 0000000000..95248b7817 --- /dev/null +++ b/iOSClient/Media/NCMedia+CollectionViewDataSourcePrefetching.swift @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2024 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later +import Foundation +import UIKit + +extension NCMedia: UICollectionViewDataSourcePrefetching { + func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { + let ext = global.getSizeExtension(column: self.numberOfColumns) +// guard !imageCache.isLoadingCache, +// imageCache.allowExtensions(ext: ext) else { +// return +// } + let cost = indexPaths.first?.row ?? 0 + let metadatas = self.dataSource.getCompactMetadatas(indexPaths: indexPaths) + + metadatas.forEach { metadata in + if self.imageCache.getImageCache(ocId: metadata.ocId, etag: metadata.etag, ext: ext) == nil, + let image = self.utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: ext, userId: self.session.userId, urlBase: self.session.urlBase) { + self.imageCache.addImageCache(ocId: metadata.ocId, etag: metadata.etag, image: image, ext: ext)//, cost: cost) + } + } + } +} diff --git a/iOSClient/Media/NCMedia.storyboard b/iOSClient/Media/NCMedia.storyboard index f43f78dea1..e962604627 100644 --- a/iOSClient/Media/NCMedia.storyboard +++ b/iOSClient/Media/NCMedia.storyboard @@ -1,25 +1,25 @@ - + - + - + - - + + - + - + @@ -27,26 +27,55 @@ - - + + + + + + + + + + + - + - - - - + + + + + + + + + + + - + + + + + + - + - + diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index 8e404dd6a2..9d61bef834 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -52,7 +52,8 @@ class NCMedia: UIViewController { } var filesExists: ThreadSafeArray = ThreadSafeArray() var ocIdDoNotExists: ThreadSafeArray = ThreadSafeArray() - var searchMediaInProgress: Bool = false +// var searchMediaInProgress: Bool = false + // Tracks whether we have completed an explicit preload before presentation private var didCompleteInitialPreload = false private var explicitPreloadTask: Task? @@ -75,12 +76,11 @@ class NCMedia: UIViewController { var currentScale: CGFloat = 1.0 var maxColumns: Int { let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) - let column = Int(screenWidth / 44) + let column = Int(screenWidth / 55) return column } var transitionColumns = false - var numberOfColumns: Int = 0 var lastNumberOfColumns: Int = 0 var loadingTask: Task? var mediaCommandView: NCMediaCommandView? @@ -90,9 +90,67 @@ class NCMedia: UIViewController { var hiddenCellMetadats: ThreadSafeArray = ThreadSafeArray() var isInGeneralPhotosSelectionContext: Bool = false + var numberOfColumns: Int = 0 { + didSet { + guard oldValue > 0, + numberOfColumns != oldValue else { + return + } + let oldExtension = global.getSizeExtension(column: oldValue) + let newExtension = global.getSizeExtension(column: numberOfColumns) + + guard oldExtension != newExtension else { + return + } + + imageCache.removeAll() + } + } + + var imageLoadingTasks: [String: Task] = [:] + let debouncerLoadDataSource = NCDebouncer(delay: .seconds(3), maxEventCount: 10) + let debouncerSearch = NCDebouncer(delay: .seconds(2), maxEventCount: 10) + + struct CollectionViewScrollAnchor { + let ocId: String + let deltaX: CGFloat + let deltaY: CGFloat + } + + var searchMediaTask: Task? + var buildDataSourceTask: Task? + + var datasourceMediaInProgress: Bool = false + var searchMediaInProgress: Bool = false { + didSet { + guard oldValue != searchMediaInProgress else { + return + } + + updateLeftBarButtonItems( + date: navigationItem.leftBarButtonItems?.first === buttonDateBarItem ? buttonDateBarItem : nil, + activity: searchMediaInProgress + ) + } + } + + internal lazy var buttonDateBarItem = UIBarButtonItem( + title: nil, + style: .plain, + target: self, + action: #selector(presentMediaDatePicker) + ) + internal var lastVisibleDateRange: (first: IndexPath, last: IndexPath)? - let debouncerLoadDataSource = NCDebouncer(maxEventCount: 10) - let debouncerSearch = NCDebouncer(maxEventCount: 10) + internal lazy var searchActivityIndicator: UIActivityIndicatorView = { + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.hidesWhenStopped = true + return activityIndicator + }() + + internal lazy var searchActivityBarButtonItem: UIBarButtonItem = { + UIBarButtonItem(customView: searchActivityIndicator) + }() @MainActor var session: NCSession.Session { @@ -115,6 +173,11 @@ class NCMedia: UIViewController { (self.tabBarController as? NCMainTabBarController)?.sceneIdentifier ?? "" } + @MainActor + internal var windowScene: UIWindowScene? { + SceneManager.shared.getWindowScene(controller: self.tabBarController as? NCMainTabBarController) + } + // var isInGeneralPhotosSelectionContext: Bool = false // MARK: - Programmatic Preload API @@ -149,6 +212,7 @@ class NCMedia: UIViewController { navigationController?.setNavigationBarAppearance() collectionView.register(UINib(nibName: "NCSectionFirstHeaderEmptyData", bundle: nil), forSupplementaryViewOfKind: mediaSectionHeader, withReuseIdentifier: "sectionFirstHeaderEmptyData") + collectionView.register(UINib(nibName: "NCMediaSectionHeader", bundle: nil), forSupplementaryViewOfKind: mediaSectionHeader, withReuseIdentifier: "sectionHeader") collectionView.register(UINib(nibName: "NCSectionFooter", bundle: nil), forSupplementaryViewOfKind: mediaSectionFooter, withReuseIdentifier: "sectionFooter") collectionView.register(UINib(nibName: "NCMediaCell", bundle: nil), forCellWithReuseIdentifier: "mediaCell") collectionView.alwaysBounceVertical = true @@ -160,12 +224,13 @@ class NCMedia: UIViewController { collectionView.dragDelegate = self collectionView.dropDelegate = self collectionView.accessibilityIdentifier = "NCMedia" - // collectionView.contentInsetAdjustmentBehavior = .never layout.sectionInset = UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2) + layout.overlaysSectionHeader = true collectionView.collectionViewLayout = layout - layoutType = database.getLayoutForView(account: session.account, key: global.layoutViewMedia, serverUrl: "", layout: global.mediaLayoutRatio).layout - +// layoutType = database.getLayoutForView(account: session.account, key: global.layoutViewMedia, serverUrl: "", layout: global.mediaLayoutRatio).layout + layoutType = database.getLayoutForView(account: session.account, key: global.layoutViewMedia, serverUrl: "", layoutType: global.mediaLayoutRatio).layout + // tabBarSelect = NCMediaSelectTabBar(controller: self.tabBarController, viewController: self, delegate: self) titleDate.text = "" @@ -215,6 +280,38 @@ class NCMedia: UIViewController { } } + titleDate.text = "" + titleDate?.textColor = .white + activityIndicator.color = .white + navigationItem.leftItemsSupplementBackButton = true + navigationItem.leftBarButtonItem = nil + gradientLayer.locations = [0.0, 0.20, 0.40, 0.60, 0.75, 0.85, 0.95, 1.0] + gradientView.layer.insertSublayer(gradientLayer, at: 0) + + activeAccount = NCManageDatabase.shared.getActiveTableAccount() ?? tableAccount() + + collectionView.refreshControl = refreshControl + refreshControl.action(for: .valueChanged) { _ in + DispatchQueue.global().async { + Task { + await self.loadDataSource() + await self.searchMediaUI(true) + } + } + self.refreshControl.endRefreshing() + } + + // Title + Activity indicator + if UIDevice.current.userInterfaceIdiom == .pad { + titleConstraint.constant = 0 + } else { + if #available(iOS 26.0, *) { + titleConstraint.constant = -44 + } else { + titleConstraint.constant = -34 + } + } + titleDate.text = "" titleDate?.textColor = .white activityIndicator.color = .white @@ -222,7 +319,14 @@ class NCMedia: UIViewController { pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) collectionView.addGestureRecognizer(pinchGesture) - NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterChangeUser), object: nil, queue: nil) { notification in + Task { + await loadDataSource() + } + + NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterChangeUser), object: nil, queue: nil) { [weak self] notification in + guard let self else { + return + } Task { @MainActor in guard let userInfo = notification.userInfo, let account = userInfo["account"] as? String else { @@ -230,17 +334,22 @@ class NCMedia: UIViewController { } self.layoutType = self.database.getLayoutForView(account: account, key: self.global.layoutViewMedia, serverUrl: "").layout + self.imageCache.removeAll() - await self.loadDataSource() + self.dataSource.clearCompactMetadatas() + self.setTitleDate() + + await self.loadDataSource(forced: true) await self.searchMediaUI(true) } } - NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterClearCache), object: nil, queue: nil) { _ in + NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in + guard let self else { + return + } Task { - await self.dataSource.clearMetadatas() - self.imageCache.removeAll() - await self.searchMediaUI(true) + await self.networkRemoveAll() } } @@ -249,33 +358,68 @@ class NCMedia: UIViewController { NotificationCenter.default.addObserver(self, selector: #selector(deleteFile(_:)), name: NSNotification.Name(rawValue: global.notificationCenterDeleteFile), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadDataSource(_:)), name: NSNotification.Name(rawValue: global.notificationCenterReloadDataSource), object: nil) - - NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { _ in + + NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterClearCache), object: nil, queue: nil) { _ in Task { - await self.networkRemoveAll() + await self.dataSource.clearCompactMetadatas() + self.imageCache.removeAll() + await self.searchMediaUI(true) } } + + NotificationCenter.default.addObserver(self, selector: #selector(fileExists(_:)), name: NSNotification.Name(rawValue: global.notificationCenterFileExists), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(deleteFile(_:)), name: NSNotification.Name(rawValue: global.notificationCenterDeleteFile), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(reloadDataSource(_:)), name: NSNotification.Name(rawValue: global.notificationCenterReloadDataSource), object: nil) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) if tabBarSelect == nil { - tabBarSelect = NCMediaSelectTabBar(controller: self.tabBarController, viewController: self, delegate: self) + tabBarSelect = NCMediaSelectTabBar( + controller: self.tabBarController, + viewController: self, + delegate: self + ) } // navigationController?.setMediaAppreance() - Task { - await (self.navigationController as? NCMediaNavigationController)?.setNavigationRightItems() + Task { [weak self] in + guard let self else { + return + } + + await (self.navigationController as? NCMediaNavigationController)? + .setNavigationRightItems() + if #unavailable(iOS 26.0) { - (self.navigationController as? NCMediaNavigationController)?.updateRightBarButtonsTint(to: .white) + (self.navigationController as? NCMediaNavigationController)? + .updateRightBarButtonsTint(to: .white) } } - if dataSource.metadatas.isEmpty { - Task { - await loadDataSource() + Task { [weak self] in + guard let self else { + return } + + await self.networking.transferDispatcher.addDelegate(self) + + guard !Task.isCancelled else { + return + } + + self.searchNewMedia() + + guard !Task.isCancelled else { + return + } + + self.collectionView.layoutIfNeeded() + self.updateImageCacheWindow() + self.setTitleDate() } Task { if !self.didCompleteInitialPreload { @@ -283,14 +427,14 @@ class NCMedia: UIViewController { await self.searchMediaUI(true) } } - AnalyticsHelper.shared.trackEvent(eventName: .SCREEN_EVENT__MEDIA) +// AnalyticsHelper.shared.trackEvent(eventName: .SCREEN_EVENT__MEDIA) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // Re-evaluate in-app messages after viewDidAppear - MoEngageAnalytics.shared.displayInAppNotificationSafely(reason: "viewDidAppear") +// MoEngageAnalytics.shared.displayInAppNotificationSafely(reason: "viewDidAppear") Task { await networking.transferDispatcher.addDelegate(self) @@ -313,9 +457,24 @@ class NCMedia: UIViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - Task { - await networking.transferDispatcher.removeDelegate(self) - await networkRemoveAll() + searchMediaTask?.cancel() + searchMediaTask = nil + + buildDataSourceTask?.cancel() + buildDataSourceTask = nil + + imageCache.removeAll() + + Task { [weak self] in + guard let self else { + return + } + + await self.debouncerSearch.cancel() + await self.debouncerLoadDataSource.cancel() + + await self.networking.transferDispatcher.removeDelegate(self) + await self.networkRemoveAll() } NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: global.notificationCenterCopyMoveFile), object: nil) @@ -323,13 +482,19 @@ class NCMedia: UIViewController { NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil) } - override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() // if let frame = tabBarController?.tabBar.frame { // tabBarSelect.hostingController?.view.frame = frame // } gradientLayer.frame = gradientView.bounds + setTitleDate() +// if let frame = tabBarController?.tabBar.frame { +// tabBarSelect.hostingController?.view.frame = frame +// } + gradientLayer.frame = gradientView.bounds + } override var preferredStatusBarStyle: UIStatusBarStyle { @@ -342,11 +507,20 @@ class NCMedia: UIViewController { } } + // MARK: - Timer search media + func searchNewMedia() { timerSearchNewMedia?.invalidate() + timerSearchNewMedia = Timer.scheduledTimer(withTimeInterval: timeIntervalSearchNewMedia, repeats: false) { [weak self] _ in - Task { [weak self] in - guard let self else { return } + guard let self else { + return + } + self.searchMediaTask?.cancel() + self.searchMediaTask = Task { [weak self] in + guard let self else { + return + } await self.searchMediaUI() } } @@ -359,8 +533,8 @@ class NCMedia: UIViewController { timerSearchNewMedia = nil filesExists.removeAll() - NCNetworking.shared.fileExistsQueue.cancelAll() - networking.downloadThumbnailQueue.cancelAll() + NCNetworking.shared.fileExistsQueue.cancel() + NCNetworking.shared.downloadThumbnailQueue.cancel() let tasks = await networking.getAllDataTask() for task in tasks.filter({ $0.taskDescription == global.taskDescriptionRetrievesProperties }) { @@ -432,7 +606,7 @@ class NCMedia: UIViewController { if NCNetworking.shared.fileExistsQueue.operationCount == 0, !ocIdDoNotExists.isEmpty, let ocIdDoNotExists = self.ocIdDoNotExists.getArray() { - dataSource.removeMetadata(ocIdDoNotExists) + dataSource.removeCompactMetadata(ocIdDoNotExists) database.deleteMetadataOcIds(ocIdDoNotExists) self.ocIdDoNotExists.removeAll() collectionViewReloadData() @@ -461,7 +635,6 @@ class NCMedia: UIViewController { case 0...1: pointSize = 60 case 2...3: pointSize = 30 case 4...5: pointSize = 25 - case 6...Int(maxColumns): pointSize = 20 default: pointSize = 20 } if let image = UIImage(systemName: "photo.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))?.withTintColor(.systemGray4, renderingMode: .alwaysOriginal) { @@ -472,6 +645,37 @@ class NCMedia: UIViewController { } } + @MainActor + func updateImageCacheWindow(force: Bool = false) { + guard !dataSource.compactMetadatas.isEmpty else { + return + } + + let visibleIndexPaths = collectionView.indexPathsForVisibleItems.sorted() + + let centerIndex: Int + + if !visibleIndexPaths.isEmpty { + let centerIndexPath = visibleIndexPaths[visibleIndexPaths.count / 2] + + guard let visibleCenterIndex = dataSource.globalIndex(for: centerIndexPath) else { + return + } + + centerIndex = visibleCenterIndex + } else { + centerIndex = 0 + } + + imageCache.updateImageCacheWindow( + imageCacheWindowItems: dataSource.imageCacheWindowItems, + centerIndex: centerIndex, + numberOfColumns: numberOfColumns, + session: session, + force: force + ) + } + // MARK: - Command func setupMediaCommandView() { @@ -515,62 +719,6 @@ class NCMedia: UIViewController { } } -// MARK: - - -extension NCMedia: UIScrollViewDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - if !dataSource.metadatas.isEmpty { - isTop = scrollView.contentOffset.y <= -(insetsTop + view.safeAreaInsets.top - 25) -// setTitleDate() - if lastContentOffsetY == 0 || lastContentOffsetY / 2 <= scrollView.contentOffset.y || lastContentOffsetY / 2 >= scrollView.contentOffset.y { - setTitleDate() - lastContentOffsetY = scrollView.contentOffset.y - } - setNeedsStatusBarAppearanceUpdate() - } - setElements() - } - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - if !decelerate { - if !decelerate { - searchNewMedia() - } - } - } - - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - searchNewMedia() - } - - func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { - let y = view.safeAreaInsets.top - scrollView.contentOffset.y = -(insetsTop + y) - } -} - -// MARK: - - -extension NCMedia: NCSelectDelegate { - func dismissSelect(serverUrl: String?, metadata: tableMetadata?, type: String, items: [Any], overwrite: Bool, copy: Bool, move: Bool, session: NCSession.Session) { - guard let serverUrl else { return } - - Task { - let home = utilityFileSystem.getHomeServer(session: session) - let mediaPath = serverUrl.replacingOccurrences(of: home, with: "") - - await database.setAccountMediaPathAsync(mediaPath, account: session.account) - - imageCache.removeAll() - await loadDataSource() - searchNewMedia() - } - } -} - // MARK: - Media Command View class NCMediaCommandView: UIView { diff --git a/iOSClient/Media/NCMediaDataSource.swift b/iOSClient/Media/NCMediaDataSource.swift index 4fc89c41c8..281a6d5587 100644 --- a/iOSClient/Media/NCMediaDataSource.swift +++ b/iOSClient/Media/NCMediaDataSource.swift @@ -110,6 +110,7 @@ extension NCMedia { @MainActor func collectionViewReloadData() { + guard isViewLoaded, let _ = collectionView else { return } collectionView.reloadData() } @@ -549,6 +550,112 @@ extension NCMedia { } } } + + /// Album-only search that is safe to call from SwiftUI sheets. + /// This relaxes some guard conditions and avoids dependence on visible cells. + func searchMediaForAlbumUI() async { + // More permissive initial checks for album context: allow running even if not yet visible + let shouldContinue = await MainActor.run { () -> Bool in + guard !self.searchMediaInProgress, + !self.isPinchGestureActive else { + return false + } + // For album loads, do not block on isViewActived, edit mode, filters, or thumbnail queue. + self.searchMediaInProgress = true + if self.isViewLoaded, self.view.window != nil { + self.activityIndicator.startAnimating() + } + return true + } + + guard shouldContinue, + let tblAccount = await self.database.getTableAccountAsync(predicate: NSPredicate(format: "account == %@", session.account)) else { + await MainActor.run { + if self.isViewLoaded, self.view.window != nil { + self.activityIndicator.stopAnimating() + } + self.searchMediaInProgress = false + } + return + } + + let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) + // For album context, fetch a reasonable window without relying on visible cells + let lessDate = Date.distantFuture + let greaterDate = Date.distantPast + + // Use a stable default limit for album sheets + let limit = 500 + + let options = NKRequestOptions(timeout: 180, + taskDescription: self.global.taskDescriptionRetrievesProperties, + queue: NextcloudKit.shared.nkCommonInstance.backgroundQueue) + + let result = await NCMediaNetwork().searchMediaAsync(path: tblAccount.mediaPath, + lessDate: lessDate, + greaterDate: greaterDate, + limit: limit, + account: self.session.account, + options: options) { task in + Task { + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: self.session.account, + name: "searchMediaAlbum") + await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) + } + } + + guard result.error == .success, let files = result.files else { + nkLog(error: "Album media search failed: \(result.error.errorDescription)") + await MainActor.run { + self.searchMediaInProgress = false + self.collectionViewReloadData() + self.activityIndicator.stopAnimating() + } + return + } + + if files.isEmpty { + await MainActor.run { + self.dataSource.clearCompactMetadatas() + self.collectionViewReloadData() + } + } + + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + + let (_, remoteMetadatas) = await NCManageDatabaseCreateMetadata().convertFilesToMetadatasAsync(files, mediaSearch: true) + let mediaPredicate = NCMedia.getMediaPredicate(session: session, + mediaPath: tblAccount.mediaPath, + showOnlyImages: self.showOnlyImages, + showOnlyVideos: self.showOnlyVideos) + + var predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "date >= %@ AND date <= %@ AND mediaSearch == true", greaterDate as NSDate, lessDate as NSDate), + mediaPredicate + ]) + + if capabilities.serverVersionMajor >= self.global.nextcloudVersionFuture { + predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "datePhotosOriginal >= %@ AND datePhotosOriginal <= %@ AND mediaSearch == true", greaterDate as NSDate, lessDate as NSDate), + mediaPredicate + ]) + } + + let localMetadatas = await self.database.getMetadatasAsync(predicate: predicate) + + await MainActor.run { + self.activityIndicator.stopAnimating() + self.searchMediaInProgress = false + } + + if await database.mergeRemoteMetadatasAsync(remoteMetadatas: remoteMetadatas, localMetadatas: localMetadatas) { + await loadDataSource() + } else if await self.dataSource.isEmpty() { + await self.collectionViewReloadData() + } + } + } // MARK: - MEDIA PREDICATE - diff --git a/iOSClient/Media/NCMediaNavigationController.swift b/iOSClient/Media/NCMediaNavigationController.swift index cb3f4de17e..7e90acff78 100644 --- a/iOSClient/Media/NCMediaNavigationController.swift +++ b/iOSClient/Media/NCMediaNavigationController.swift @@ -7,7 +7,9 @@ import NextcloudKit import SwiftUI class NCMediaNavigationController: NCMainNavigationController { + static let photosAddedToAlbumNotification = Notification.Name("NCMediaPhotosAddedToAlbumNotification") + static let showAlbumDetailsNotification = Notification.Name("NCMediaShowAlbumDetailsNotification") // MARK: - Right @@ -46,7 +48,7 @@ class NCMediaNavigationController: NCMainNavigationController { desiredItems.append(transfersButtonItem) if let optionMenu = await self.createOptionMenu() { - setOptionMenu(optionMenu) + optionButtonItem.menu = optionMenu desiredItems.append(optionButtonItem) } @@ -62,97 +64,79 @@ class NCMediaNavigationController: NCMainNavigationController { guard let media = topViewController as? NCMedia else { return nil } + let layoutForView = database.getLayoutForView(account: session.account, key: global.layoutViewMedia, serverUrl: "", layoutType: global.mediaLayoutRatio) + var layout = layoutForView.layout + // Overwrite default value + if layout == global.layoutList { + layout = global.mediaLayoutRatio + } + // + let layoutTitle = (layout == global.mediaLayoutRatio) ? NSLocalizedString("_media_square_", comment: "") : NSLocalizedString("_media_ratio_", comment: "") + let layoutImage = (layout == global.mediaLayoutRatio) ? utility.loadImage(named: "square.grid.3x3") : utility.loadImage(named: "rectangle.grid.3x2") + let select = UIAction(title: NSLocalizedString("_select_", comment: ""), - image: utility.loadImage(named: "checkmark.circle")) { _ in + image: utility.loadImage(named: "checkmark.circle", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor)) { _ in media.setEditMode(true) Task { + await media.loadDataSource() + await media.networkRemoveAll() await self.updateMenuOption() } } - + let cancel = UIAction(title: NSLocalizedString("_cancel_", comment: ""), image: utility.loadImage(named: "xmark", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor)) { _ in media.setEditMode(false) Task { await media.loadDataSource() await media.networkRemoveAll() + await self.updateMenuOption() } } - - let viewFilterMenu = UIMenu(title: "", options: .displayInline, children: [ - UIDeferredMenuElement.uncached { [weak self, weak media] completion in - guard let self, let media else { - completion([]) - return + + let viewFilterMenu = UIMenu(title: "", options: [.singleSelection, .displayInline], children: [ + UIAction(title: NSLocalizedString("_media_viewimage_show_", comment: ""), image: utility.loadImage(named: "photo")) { _ in + media.showOnlyImages = true + media.showOnlyVideos = false + Task { + await media.loadDataSource() + await media.networkRemoveAll() + } + }, + UIAction(title: NSLocalizedString("_media_viewvideo_show_", comment: ""), image: utility.loadImage(named: "video")) { _ in + media.showOnlyImages = false + media.showOnlyVideos = true + Task { + await media.loadDataSource() + await media.networkRemoveAll() + } + }, + UIAction(title: NSLocalizedString("_media_show_all_", comment: ""), image: utility.loadImage(named: "photo.on.rectangle"), state: .on) { _ in + media.showOnlyImages = false + media.showOnlyVideos = false + Task { + await media.loadDataSource() + await media.networkRemoveAll() } - let actions = [ - UIAction(title: NSLocalizedString("_media_viewimage_show_", comment: ""), image: self.utility.loadImage(named: "photo"), state: media.showOnlyImages ? .on : .off) { _ in - media.showOnlyImages = true - media.showOnlyVideos = false - Task { - await media.loadDataSource() - await media.networkRemoveAll() - } - }, - - UIAction(title: NSLocalizedString("_media_viewvideo_show_", comment: ""), image: self.utility.loadImage(named: "video"), state: media.showOnlyVideos ? .on : .off) { _ in - media.showOnlyImages = false - media.showOnlyVideos = true - Task { - await media.loadDataSource() - await media.networkRemoveAll() - } - }, - - UIAction(title: NSLocalizedString("_media_show_all_", comment: ""), image: self.utility.loadImage(named: "photo.on.rectangle"), state: (!media.showOnlyImages && !media.showOnlyVideos) ? .on : .off) { _ in - media.showOnlyImages = false - media.showOnlyVideos = false - Task { - await media.loadDataSource() - await media.networkRemoveAll() - } - } - ] - - completion(actions) } ]) let viewLayoutMenu = UIMenu(title: "", options: .displayInline, children: [ - UIDeferredMenuElement.uncached { [weak self] completion in - guard let self else { - completion([]) - return - } - - let layoutForView = self.database.getLayoutForView(account: self.session.account, key: self.global.layoutViewMedia, serverUrl: "", layoutType: self.global.mediaLayoutRatio) - var layout = layoutForView.layout - - // Overwrite default value - if layout == self.global.layoutList { - layout = self.global.mediaLayoutRatio - } - - let layoutTitle = (layout == self.global.mediaLayoutRatio) ? NSLocalizedString("_media_square_", comment: "") : NSLocalizedString("_media_ratio_", comment: "") - let layoutImage = (layout == self.global.mediaLayoutRatio) ? self.utility.loadImage(named: "square.grid.3x3") : self.utility.loadImage(named: "rectangle.grid.3x2") - - let action = UIAction(title: layoutTitle, image: layoutImage) { _ in - Task { - if layout == self.global.mediaLayoutRatio { - self.database.setLayoutForView(account: self.session.account, key: self.global.layoutViewMedia, serverUrl: "", layout: self.global.mediaLayoutSquare) - media.layoutType = self.global.mediaLayoutSquare - } else { - self.database.setLayoutForView(account: self.session.account, key: self.global.layoutViewMedia, serverUrl: "", layout: self.global.mediaLayoutRatio) - media.layoutType = self.global.mediaLayoutRatio - } - - media.collectionViewReloadData() + UIAction(title: layoutTitle, image: layoutImage) { _ in + Task { + if layout == self.global.mediaLayoutRatio { + self.database.setLayoutForView(account: self.session.account, key: self.global.layoutViewMedia, serverUrl: "", layout: self.global.mediaLayoutSquare) + media.layoutType = self.global.mediaLayoutSquare + } else { + self.database.setLayoutForView(account: self.session.account, key: self.global.layoutViewMedia, serverUrl: "", layout: self.global.mediaLayoutRatio) + media.layoutType = self.global.mediaLayoutRatio } + await self.updateMenuOption() + media.collectionViewReloadData() } - - completion([action]) } ]) + let viewFolderMedia = UIMenu(title: "", options: .displayInline, children: [ UIDeferredMenuElement.uncached { [weak self] completion in guard let self else { @@ -210,49 +194,48 @@ class NCMediaNavigationController: NCMainNavigationController { })) self.present(alert, animated: true) } - + let selectAll = UIMenu(title: "", options: .displayInline, children: [ UIAction( title: NSLocalizedString("_select_all_", comment: ""), - image: utility.loadImage(named: "checkmark.circle.fill"), + image: utility.loadImage(named: "checkmark.circle.fill", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor),//, colors: [NCBrandColor.shared.iconImageColor]), handler: { _ in - // Ensure edit mode is enabled so selection is visible - if !media.isEditMode { - media.setEditMode(true) - } - - // Toggle select all / clear selection if !media.fileSelect.isEmpty, media.dataSource.compactMetadatas.count == media.fileSelect.count { media.fileSelect = [] } else { - media.fileSelect = media.dataSource.compactMetadatas.compactMap { $0.ocId } + media.fileSelect = media.dataSource.compactMetadatas.compactMap({ $0.ocId }) } - - // Update selection count and refresh UI without reloading data source media.tabBarSelect.selectCount = media.fileSelect.count - media.collectionViewReloadData() + Task { + await media.loadDataSource() + await media.networkRemoveAll() + await self.updateMenuOption() + } } ) ]) - + let actionsInEditMode: [UIAction] = [ + UIAction( title: NSLocalizedString("_add_to_album", comment: ""), - image: utility.loadImage(named: "photo.badge.plus.fill"), + image: utility.loadImage(named: "plus", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor), handler: { _ in guard let controller = self.controller else { return } - NCMediaNavigationController.presentExistingAlbums(controller: controller, selectedPhotos: media.fileSelect) + NCMediaNavigationController.presentExistingAlbums(presentingController: controller, selectedPhotos: media.fileSelect, account: controller.account) } ), - + UIAction( title: NSLocalizedString("_albums_list_new_album_popup_title_", comment: ""), - image: utility.loadImage(named: "photo.stack.fill"), + image: utility.loadImage(named: "album", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor), handler: { _ in guard let controller = self.controller else { return } NCMediaNavigationController.presentInputAlbumNameAlert(on: controller) { albumName in - NCMediaNavigationController.createNewAlbum(for: albumName, selectedPhotos: media.fileSelect, controller: controller) - } onCancel: { } + NCMediaNavigationController.createNewAlbum(for: albumName, selectedPhotos: media.fileSelect, controller: controller, account: controller.account) + } onCancel: { + + } } ) ] @@ -262,22 +245,22 @@ class NCMediaNavigationController: NCMainNavigationController { options: .displayInline, children: actionsInEditMode ) - return UIMenu(title: "", children: !media.isEditMode ? [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL] : [cancel, selectAll, editModeMenu]) - } + return UIMenu(title: "", children: !media.isEditMode ? [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL] : [cancel, selectAll, editModeMenu])//, playFile, playURL]) +// return UIMenu(title: "", children: [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL]) + } + // MARK: - Album related handling @objc private func handlePhotosAddedToAlbumNotification(_ notification: Notification) { - guard let sourceController = notification.object as? NCMainTabBarController, - sourceController === controller, - let media = topViewController as? NCMedia else { return } + guard let media = topViewController as? NCMedia else { return } media.setEditMode(false) Task { await media.loadDataSource() await media.networkRemoveAll() -// await self.updateMenuOption() + await self.updateMenuOption() } } - + static func presentInputAlbumNameAlert( on viewController: UIViewController, onCreate: @escaping (String) -> Void, @@ -288,15 +271,15 @@ class NCMediaNavigationController: NCMainNavigationController { message: NSLocalizedString("_albums_list_new_album_popup_desc_", comment: ""), preferredStyle: .alert ) - + alert.addTextField { textField in textField.placeholder = NSLocalizedString("_albums_list_new_album_popup_hint_", comment: "") } - + alert.addAction(UIAlertAction(title: NSLocalizedString("_albums_list_new_album_popup_negative_btn_", comment: ""), style: .default) { _ in onCancel() }) - + alert.addAction(UIAlertAction(title: NSLocalizedString("_albums_list_new_album_popup_positive_btn_", comment: ""), style: .default) { _ in let text = alert.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if text.isEmpty { @@ -311,127 +294,151 @@ class NCMediaNavigationController: NCMainNavigationController { onCreate(text) } }) - + alert.view.tintColor = NCBrandColor.shared.customer viewController.present(alert, animated: true) } - - static private func createNewAlbum(for name: String, selectedPhotos: [String], controller: NCMainTabBarController) { - let loader = NCLoadingAlert.show(on: controller) - - NextcloudKit.shared.createNewAlbum(for: controller.account, albumName: name) { result in - NCLoadingAlert.hide(loader) + + static private func createNewAlbum(for name: String, selectedPhotos: [String], controller: UIViewController, account: String) { + + // Use the provided account to avoid mismatches between UI and networking + // (Do not rely on AppDelegate.account here) + + controller.showLoader() + NextcloudKit.shared.createNewAlbum(for: account, albumName: name) { result in + controller.hideLoader() switch result { - case .success(let account): - AlbumsManager.shared.syncAlbums(for: account) { resultAlbums in + case .success(_): + AlbumsManager.shared.syncAlbums { resultAlbums in if let newAlbum = resultAlbums.first(where: { $0.name == name }) { if selectedPhotos.isEmpty { - showAlbumAndNotify(newAlbum, controller: controller) + showAlbumAndNotify(newAlbum) } else { - addPhotosToAlbum(album: newAlbum, selectedPhotos: selectedPhotos, controller: controller) + addPhotosToAlbum(album: newAlbum, selectedPhotos: selectedPhotos, account: account) } } else { // Album not yet visible in the sync result; still notify UI to refresh - NotificationCenter.default.post(name: NCMediaNavigationController.photosAddedToAlbumNotification, object: controller) + NotificationCenter.default.post(name: NCMediaNavigationController.photosAddedToAlbumNotification, object: nil) } } - + case .failure(let error): - Task { - await showErrorBanner(windowScene: controller.viewIfLoaded?.window?.windowScene, error: error) + let nkError = NKError(error: error) + // Prefer friendly info alert for duplicate album names (409) + if let inner = nkError.error as? NKError, inner.errorCode == NCGlobal.shared.errorConflict { + let message = NSLocalizedString("_album_already_exists_", comment: "Album already exists") + let conflict = NKError(errorCode: NCGlobal.shared.errorConflict, errorDescription: message) + NCContentPresenter().showInfo(error: conflict) + } else if nkError.errorCode == NCGlobal.shared.errorConflict { + // Top-level conflict + let message = NSLocalizedString("_album_already_exists_", comment: "Album already exists") + let conflict = NKError(errorCode: NCGlobal.shared.errorConflict, errorDescription: message) + NCContentPresenter().showInfo(error: conflict) + } else { + // Other errors + NCContentPresenter().showError(error: nkError) } } } } - - static func presentExistingAlbums(controller: NCMainTabBarController, selectedPhotos: [String]) { - let viewModel = AlbumsListViewModel(controller: controller) - let albumListView = AddToAlbumsListView(viewModel: viewModel, controller: controller, onFinish: { selectedAlbum in - controller.dismiss(animated: true) { - addPhotosToAlbum(album: selectedAlbum, selectedPhotos: selectedPhotos, controller: controller) - } + + static func presentExistingAlbums(presentingController: UIViewController,selectedPhotos: [String], account: String) { + let viewModel = AlbumsListViewModel(account: account) + let albumListView = AddToAlbumsListView(viewModel: viewModel, localAccount: account, onFinish: { selectedAlbum in + presentingController.dismiss(animated: true) + addPhotosToAlbum(album: selectedAlbum, selectedPhotos: selectedPhotos, account: account) }, onDismiss: { - controller.dismiss(animated: true) + presentingController.dismiss(animated: true) }, onCreateAlbum: { - controller.dismiss(animated: true) { - presentInputAlbumNameAlert(on: controller) { albumName in - createNewAlbum(for: albumName, selectedPhotos: selectedPhotos, controller: controller) - } onCancel: { } + presentingController.dismiss(animated: true) + presentInputAlbumNameAlert(on: presentingController) { albumName in + createNewAlbum(for: albumName, selectedPhotos: selectedPhotos, controller: presentingController, account: account) + } onCancel: { + } }) - + let hostingController = UIHostingController(rootView: albumListView) if let sheet = hostingController.sheetPresentationController { sheet.detents = [.large()] sheet.prefersGrabberVisible = true sheet.preferredCornerRadius = 24 } - controller.present(hostingController, animated: true, completion: nil) + presentingController.present(hostingController, animated: true, completion: nil) } - - private static func showAlbumAndNotify(_ album: Album, controller: NCMainTabBarController) { + + private static func showAlbumAndNotify(_ album: Album) { DispatchQueue.main.async { - guard controller.viewIfLoaded?.window != nil, - let navigationController = controller.viewControllers?.compactMap({ $0 as? NCMoreNavigationController }).first, - let albumsController = UIStoryboard(name: "NCAlbums", bundle: nil) - .instantiateInitialViewController() as? AlbumsViewController else { - return - } - - albumsController.initialAlbum = album - controller.selectedViewController = navigationController - guard let moreController = navigationController.viewControllers.first else { return } - navigationController.setViewControllers([moreController, albumsController], animated: true) - NotificationCenter.default.post(name: photosAddedToAlbumNotification, object: controller) + // Ensure Albums tab is selected and get its navigation controller + let nav = ensureAlbumsContextSelectedAndGetNavController() + // Pop the Albums navigation stack to root (e.g., dashboard) to ensure a clean state + nav?.popToRootViewController(animated: false) + // Notify listeners to show the album details + NotificationCenter.default.post(name: NCMediaNavigationController.showAlbumDetailsNotification, object: album) + // Let Media UI exit edit mode and refresh + NotificationCenter.default.post(name: NCMediaNavigationController.photosAddedToAlbumNotification, object: nil) } } - - static func addPhotosToAlbum(album: Album, selectedPhotos: [String], controller: NCMainTabBarController) { + + static func addPhotosToAlbum(album: Album, selectedPhotos: [String], account: String) { + if selectedPhotos.isEmpty { - showAlbumAndNotify(album, controller: controller) + showAlbumAndNotify(album) return } - + var completed = 0 - var succeeded = 0 let total = selectedPhotos.count func finishIfDone() { completed += 1 - guard completed == total, succeeded > 0 else { return } - AlbumsManager.shared.invalidatePhotoRequest(for: album) - AlbumsManager.shared.syncAlbums(for: controller.account) - showAlbumAndNotify(album, controller: controller) + if completed >= total { + AlbumsManager.shared.syncAlbums() + showAlbumAndNotify(album) + } } - + for photo in selectedPhotos { - guard let metadata = NCManageDatabase.shared.getMetadataFromOcId(photo) else { - Task { - await showErrorBanner(windowScene: controller.viewIfLoaded?.window?.windowScene, error: .invalidData) - } - finishIfDone() - continue - } - + + let metadata: tableMetadata? = NCManageDatabase.shared.getMetadataFromOcId(photo) + NextcloudKit.shared.copyPhotoToAlbum( - account: controller.account, - sourcePath: metadata.serverUrlFileName, + account: account, + sourcePath: metadata?.serverUrlFileName ?? photo, albumName: album.name, - fileName: metadata.fileName + fileName: metadata?.fileName ?? photo ) { result in + switch result { case .success: - succeeded += 1 finishIfDone() case .failure(let error): - Task { - await showErrorBanner(windowScene: controller.viewIfLoaded?.window?.windowScene, error: error) + let nkError = NKError(error: error) + if let innerError = nkError.error as? NKError, innerError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, errorDescription: "_file_already_exists_") + NCContentPresenter().showInfo(error: conflictError) + } else if nkError.errorCode == NCGlobal.shared.errorConflict { + NCContentPresenter().showInfo(error: nkError) + } else { + NCContentPresenter().showError(error: nkError) } finishIfDone() } } + } } - + + static func ensureAlbumsContextSelectedAndGetNavController() -> UINavigationController? { + guard let tabbarController = UIApplication.shared.firstWindow?.rootViewController as? NCMainTabBarController else { return nil } + tabbarController.selectedIndex = NCGlobal.shared.selectedTabIndexAlbum + // Try to fetch the selected view controller as a navigation controller + if let nav = tabbarController.selectedViewController as? UINavigationController { + return nav + } + // Fallback: if the tab bar controller has embedded navigation controllers + return tabbarController.navigationController ?? tabbarController.selectedViewController?.navigationController + } + deinit { NotificationCenter.default.removeObserver(self, name: Self.photosAddedToAlbumNotification, object: nil) } diff --git a/iOSClient/Media/NCMediaNetwork.swift b/iOSClient/Media/NCMediaNetwork.swift index ef3f352a53..39e054aae9 100644 --- a/iOSClient/Media/NCMediaNetwork.swift +++ b/iOSClient/Media/NCMediaNetwork.swift @@ -193,6 +193,53 @@ final class NCMediaNetwork { """ return request } + + func searchMediaAsync(path: String = "", + lessDate: Date, + greaterDate: Date, + limit: Int, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, files: [NKFile]?, error: NKError) { + guard let nkSession = NextcloudKit.shared.nkCommonInstance.nksessions.session(forAccount: account) else { + return (account, nil, .urlError) + } + let capabilities = await NKCapabilities.shared.getCapabilities(for: account) + let files: [NKFile] = [] + let href = "/files/" + nkSession.userId + path + + let elementDate: String + var lessDateString: String + var greaterDateString: String + + if capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersionFuture { + elementDate = "nc:metadata-photos-original_date_time" + lessDateString = String(lessDate.timeIntervalSince1970) + greaterDateString = String(greaterDate.timeIntervalSince1970) + } else { + elementDate = "d:getlastmodified" + lessDateString = lessDate.formatted(using: "yyyy-MM-dd'T'HH:mm:ssZZZZZ") + greaterDateString = greaterDate.formatted(using: "yyyy-MM-dd'T'HH:mm:ssZZZZZ") + } + + let httpBodyString = String(format: getRequestBodySearchMedia( + href: href, + elementDate: elementDate, + lessDate: lessDateString, + greaterDate: greaterDateString, + limit: String(limit)) + ) + + guard let httpBody = httpBodyString.data(using: .utf8) else { + return (account, files, .invalidData) + + } + + let results = await NextcloudKit.shared.searchAsync(serverUrl: nkSession.urlBase, httpBody: httpBody, showHiddenFiles: false, includeHiddenFiles: [], account: account, options: options, taskHandler: taskHandler) + + return(results.account, results.files, results.error) + } } extension Date { diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index ed6414252c..dce9322032 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -48,6 +48,29 @@ final class NCGlobal: Sendable { let editorOnlyOffice: String = "onlyoffice" let editorWhiteboard: String = "whiteboard" + // Nextcloud version + // + let nextcloudVersion18: Int = 18 + let nextcloudVersion20: Int = 20 + let nextcloudVersion23: Int = 23 + let nextcloudVersion24: Int = 24 + let nextcloudVersion25: Int = 25 + let nextcloudVersion26: Int = 26 + let nextcloudVersion27: Int = 27 + let nextcloudVersion28: Int = 28 + let nextcloudVersion30: Int = 30 + let nextcloudVersion31: Int = 31 + let nextcloudVersion32: Int = 32 + let nextcloudVersion33: Int = 33 + let nextcloudVersionFuture: Int = 99999 + + + // Nextcloud unsupported + // + let nextcloud_unsupported_version: Int = 20 + +// let nextcloud_unsupported_version: Int = 17 + // Intro selector // let introLogin: Int = 0 @@ -131,6 +154,7 @@ final class NCGlobal: Sendable { // Error // + let errorRequestExplicityCancelled: Int = 15 let errorNotModified: Int = 304 let errorBadRequest: Int = 400 let errorUnauthorized: Int = 401 @@ -432,6 +456,10 @@ final class NCGlobal: Sendable { // let udMigrationMultiDomains = "migrationMultiDomains" let udLastVersion = "lastVersion" + + // Album + // + let selectedTabIndexAlbum: Int = 3 } /** diff --git a/iOSClient/NCImageCache.swift b/iOSClient/NCImageCache.swift index 643e77de41..03528eed13 100644 --- a/iOSClient/NCImageCache.swift +++ b/iOSClient/NCImageCache.swift @@ -48,6 +48,23 @@ final class NCImageCache: @unchecked Sendable { private func cacheKey(ocId: String, etag: String, ext: String) -> NSString { imageCacheKey(ocId: ocId, etag: etag, ext: ext) as NSString } + +// func allowExtensions(ext: String) -> Bool { +// return allowExtensions.contains(ext) +// } +// +// func addImageCache(ocId: String, etag: String, data: Data, ext: String, cost: Int) { +// guard allowExtensions.contains(ext), +// let image = UIImage(data: data) else { return } +// +// cache.setValue(image, forKey: ocId + etag + ext, cost: cost) +// } +// +// func addImageCache(ocId: String, etag: String, image: UIImage, ext: String, cost: Int) { +// guard allowExtensions.contains(ext) else { return } +// +// cache.setValue(image, forKey: ocId + etag + ext, cost: cost) +// } func addImageCache(ocId: String, etag: String, image: UIImage, ext: String) { cache.setObject(image, forKey: cacheKey(ocId: ocId, etag: etag, ext: ext)) diff --git a/iOSClient/Utility/NCContentPresenter.swift b/iOSClient/Utility/NCContentPresenter.swift new file mode 100644 index 0000000000..82249c46cf --- /dev/null +++ b/iOSClient/Utility/NCContentPresenter.swift @@ -0,0 +1,332 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2019 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftEntryKit +import UIKit +import CFNetwork +import NextcloudKit + +class NCContentPresenter: NSObject { + typealias MainFont = Font.HelveticaNeue + enum Font { + enum HelveticaNeue: String { + case ultraLightItalic = "UltraLightItalic" + case medium = "Medium" + case mediumItalic = "MediumItalic" + case ultraLight = "UltraLight" + case italic = "Italic" + case light = "Light" + case thinItalic = "ThinItalic" + case lightItalic = "LightItalic" + case bold = "Bold" + case thin = "Thin" + case condensedBlack = "CondensedBlack" + case condensedBold = "CondensedBold" + case boldItalic = "BoldItalic" + + func with(size: CGFloat) -> UIFont { + return UIFont(name: "HelveticaNeue-\(rawValue)", size: size)! + } + } + } + + @objc enum messageType: Int { + case error + case success + case info + } + + // MARK: - Message + + func showCustomMessage(title: String = "", + message: String, + priority: EKAttributes.Precedence.Priority = .normal, + delay: TimeInterval = NCGlobal.shared.dismissAfterSecond, + type: messageType) { + self.flatTop(title: NSLocalizedString(title, comment: ""), + description: message, + delay: delay, + type: type, + priority: priority, + dropEnqueuedEntries: false) + } + + func showError(error: NKError, priority: EKAttributes.Precedence.Priority = .normal) { + messageNotification( + "_error_", + error: error, + delay: NCGlobal.shared.dismissAfterSecond, + type: .error, + priority: priority) + } + + func showInfo(error: NKError, priority: EKAttributes.Precedence.Priority = .normal) { + messageNotification( + "_info_", + error: error, + delay: NCGlobal.shared.dismissAfterSecond, + type: .info, + priority: priority) + } + + func showInfo(title: String = "", description: String = "") { + self.flatTop(title: NSLocalizedString(title, comment: ""), + description: NSLocalizedString(description, comment: ""), + delay: NCGlobal.shared.dismissAfterSecond, + type: .info, + priority: .normal) + } + + func showWarning(error: NKError, priority: EKAttributes.Precedence.Priority = .normal) { + messageNotification( + "_warning_", + error: error, + delay: NCGlobal.shared.dismissAfterSecond, + type: .info, + priority: priority) + } + + func messageNotification(_ title: String, error: NKError, delay: TimeInterval, type: messageType, afterDelay: TimeInterval = 0) { + messageNotification(title, + error: error, + delay: delay, + type: type, + priority: .normal, + dropEnqueuedEntries: false, + afterDelay: afterDelay) + } + + func messageNotification(_ title: String, + error: NKError, + delay: TimeInterval, + type: messageType, + priority: EKAttributes.Precedence.Priority = .normal, + dropEnqueuedEntries: Bool = false, + afterDelay: TimeInterval = 0) { + DispatchQueue.main.asyncAfter(deadline: .now() + afterDelay) { + // No notification message for: + if error.errorCode == NSURLErrorCancelled || error.errorCode == NCGlobal.shared.errorRequestExplicityCancelled { + return + } else if error == .success && type == messageType.error { + return + } + + // Hardcode change type + var type = type + if error.errorCode == NCGlobal.shared.errorE2EEUploadInProgress { + type = .info + } + + switch error.errorCode { + case Int(CFNetworkErrors.cfurlErrorNotConnectedToInternet.rawValue): + let image = UIImage(named: "InfoNetwork")?.image(color: .white, size: 20) + self.noteTop(text: NSLocalizedString("_network_not_available_", comment: ""), image: image, color: .lightGray, delay: delay, priority: .max) + default: + var responseMessage = "" + if let data = error.responseData { + do { + if let json = try JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String: Any], + let message = json["message"] as? String { + responseMessage = "\n\n" + message + } + } catch { + print("Something went wrong") + } + } + if error.errorDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return } +// var description = NSLocalizedString(error.errorDescription, comment: "") + ", Code: \(error.errorCode)" + var description = NSLocalizedString(error.errorDescription, comment: "")// + ", Code: \(error.errorCode)" + description = description.replacingOccurrences(of: "\t", with: "\n") + self.flatTop(title: NSLocalizedString(title, comment: ""), description: description + responseMessage, delay: delay, type: type, priority: priority, dropEnqueuedEntries: dropEnqueuedEntries) + } + } + } + + func showProcessingNote() { + DispatchQueue.main.async { + var attributes = EKAttributes.topFloat + let text = "Waiting for the goodies to arrive!" + let style = EKProperty.LabelStyle( + font: MainFont.light.with(size: 14), + color: .white, + alignment: .center, + displayMode: EKAttributes.DisplayMode.inferred + ) + let labelContent = EKProperty.LabelContent( + text: text, + style: style + ) + let contentView = EKProcessingNoteMessageView( + with: labelContent, + activityIndicator: .medium + ) + attributes.windowLevel = .normal + attributes.entryBackground = .color(color: EKColor(self.getBackgroundColorFromType(.info))) + attributes.displayDuration = .infinity + attributes.positionConstraints.verticalOffset = 20 + attributes.positionConstraints.size = .init(width: .offset(value: 20), height: .intrinsic) + attributes.positionConstraints.maxSize = .init(width: .constant(value: min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)), height: .intrinsic) + attributes.shadow = .active(with: .init(color: .black, opacity: 0.5, radius: 10, offset: .zero)) + attributes.scroll = .enabled(swipeable: true, pullbackAnimation: .jolt) + attributes.popBehavior = .animated(animation: .init(translate: .init(duration: 0.2))) + + SwiftEntryKit.display(entry: contentView, using: attributes) + } + } + + func flatTop(title: String, + description: String, + delay: TimeInterval, + type: messageType, + priority: EKAttributes.Precedence.Priority = .normal, + dropEnqueuedEntries: Bool = false) { + DispatchQueue.main.async { [self] in + if SwiftEntryKit.isCurrentlyDisplaying(entryNamed: title + description) { + return + } + var attributes = EKAttributes.topFloat + + attributes.windowLevel = .normal + attributes.displayDuration = delay + attributes.name = title + description + attributes.entryBackground = .color(color: EKColor(self.getBackgroundColorFromType(type))) + attributes.popBehavior = .animated(animation: .init(translate: .init(duration: 0.3), scale: .init(from: 1, to: 0.7, duration: 0.7))) + attributes.shadow = .active(with: .init(color: .black, opacity: 0.5, radius: 10, offset: .zero)) + attributes.scroll = .enabled(swipeable: true, pullbackAnimation: .jolt) + attributes.precedence = .override(priority: priority, dropEnqueuedEntries: dropEnqueuedEntries) + + let title = EKProperty.LabelContent(text: title, style: .init(font: MainFont.bold.with(size: 16), color: .white)) + let description = EKProperty.LabelContent(text: description, style: .init(font: MainFont.medium.with(size: 13), color: .white)) + let imageMessage = EKProperty.ImageContent(image: getImageFromType(type), size: CGSize(width: 35, height: 35)) + let simpleMessage = EKSimpleMessage(image: imageMessage, title: title, description: description) + let notificationMessage = EKNotificationMessage(simpleMessage: simpleMessage) + let contentView = EKNotificationMessageView(with: notificationMessage) + + SwiftEntryKit.display(entry: contentView, using: attributes) + } + } + + func alertAction(image: UIImage, + contentModeImage: UIView.ContentMode = .scaleToFill, + sizeImage: CGSize = CGSize(width: 34, height: 34), + backgroundColor: UIColor, + textColor: UIColor, + title: String, + description: String, + textCancelButton: String, + textOkButton: String, + attributes: EKAttributes, + completion: @escaping (_ identifier: String) -> Void) { + DispatchQueue.main.async { + var buttonAttributes: EKAttributes { + var attributes = attributes + + attributes.hapticFeedbackType = .success + attributes.displayDuration = .infinity + attributes.entryBackground = .color(color: EKColor(backgroundColor)) + attributes.shadow = .active(with: .init(color: .black, opacity: 0.3, radius: 8)) + attributes.screenInteraction = .dismiss + attributes.entryInteraction = .absorbTouches + attributes.scroll = .enabled(swipeable: true, pullbackAnimation: .jolt) + attributes.roundCorners = .all(radius: 25) + attributes.entranceAnimation = .init(translate: .init(duration: 0.7, spring: .init(damping: 1, initialVelocity: 0)), scale: .init(from: 1.05, to: 1, duration: 0.4, spring: .init(damping: 1, initialVelocity: 0))) + attributes.exitAnimation = .init(translate: .init(duration: 0.2)) + attributes.popBehavior = .animated(animation: .init(translate: .init(duration: 0.2))) + attributes.positionConstraints.verticalOffset = 20 + attributes.positionConstraints.size = .init(width: .offset(value: 20), height: .intrinsic) + attributes.positionConstraints.maxSize = .init(width: .constant(value: min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)), height: .intrinsic) + attributes.statusBar = .dark + return attributes + } + let simpleMessage = EKSimpleMessage(image: EKProperty.ImageContent(image: image, size: sizeImage, contentMode: contentModeImage), + title: EKProperty.LabelContent(text: NSLocalizedString(title, comment: ""), style: .init(font: MainFont.medium.with(size: 15), color: EKColor(textColor)), accessibilityIdentifier: "title"), + description: EKProperty.LabelContent(text: NSLocalizedString(description, comment: ""), style: .init( font: MainFont.light.with(size: 13), color: EKColor(textColor)), accessibilityIdentifier: "description")) + let cancelButton = EKProperty.ButtonContent(label: EKProperty.LabelContent(text: NSLocalizedString(textCancelButton, comment: ""), style: EKProperty.LabelStyle(font: MainFont.medium.with(size: 16), color: EKColor(textColor))), + backgroundColor: .clear, + highlightedBackgroundColor: EKColor(UIColor.lightGray), + accessibilityIdentifier: "close") { + SwiftEntryKit.dismiss() + completion("close") + } + let okButton = EKProperty.ButtonContent(label: EKProperty.LabelContent(text: NSLocalizedString(textOkButton, comment: ""), style: EKProperty.LabelStyle(font: MainFont.medium.with(size: 16), color: EKColor(textColor))), + backgroundColor: .clear, + highlightedBackgroundColor: EKColor(UIColor.lightGray), + displayMode: EKAttributes.DisplayMode.inferred, + accessibilityIdentifier: "ok") { + SwiftEntryKit.dismiss() + completion("ok") + } + let buttonsBarContent = EKProperty.ButtonBarContent(with: cancelButton, okButton, separatorColor: EKColor(textColor), buttonHeight: 60, expandAnimatedly: true) + let alertMessage = EKAlertMessage(simpleMessage: simpleMessage, imagePosition: .left, buttonBarContent: buttonsBarContent) + let contentView = EKAlertMessageView(with: alertMessage) + + SwiftEntryKit.display(entry: contentView, using: buttonAttributes) + } + } + + func noteTop(text: String, image: UIImage?, color: UIColor? = nil, type: messageType? = nil, delay: TimeInterval, priority: EKAttributes.Precedence.Priority = .normal, dropEnqueuedEntries: Bool = false) { + DispatchQueue.main.async { + if SwiftEntryKit.isCurrentlyDisplaying(entryNamed: text) { + return + } + var attributes = EKAttributes.topNote + + attributes.windowLevel = .normal + attributes.displayDuration = delay + attributes.name = text + if let color = color { + attributes.entryBackground = .color(color: EKColor(color)) + } + if let type = type { + attributes.entryBackground = .color(color: EKColor(self.getBackgroundColorFromType(type))) + } + attributes.precedence = .override(priority: priority, dropEnqueuedEntries: dropEnqueuedEntries) + let style = EKProperty.LabelStyle(font: MainFont.light.with(size: 14), color: .white, alignment: .center) + let labelContent = EKProperty.LabelContent(text: text, style: style) + + if let image = image { + let imageContent = EKProperty.ImageContent(image: image, size: CGSize(width: 17, height: 17)) + let contentView = EKImageNoteMessageView(with: labelContent, imageContent: imageContent) + SwiftEntryKit.display(entry: contentView, using: attributes) + } else { + let contentView = EKNoteMessageView(with: labelContent) + SwiftEntryKit.display(entry: contentView, using: attributes) + } + } + } + + func dismiss(after: TimeInterval = 0) { + DispatchQueue.main.asyncAfter(deadline: .now() + after) { + SwiftEntryKit.dismiss() + } + } + + // MARK: - Private + + private func getBackgroundColorFromType(_ type: messageType) -> UIColor { + switch type { + case .info: + return NCBrandColor.shared.customer + case .error: + return UIColor(red: 1, green: 0, blue: 0, alpha: 0.9) + case .success: + return UIColor(red: 0.588, green: 0.797, blue: 0, alpha: 0.9) + default: + return .white + } + } + + private func getImageFromType(_ type: messageType) -> UIImage { + switch type { + case .info: + return UIImage(named: "iconInfo")! + case .error: + return UIImage(named: "iconError")! + case .success: + return UIImage(named: "iconSuccess")! + default: + return UIImage(named: "iconInfo")! + } + } +} diff --git a/iOSClient/Utility/NCUtility+Image.swift b/iOSClient/Utility/NCUtility+Image.swift index 14dd0c58f0..0f9d4c2d6f 100644 --- a/iOSClient/Utility/NCUtility+Image.swift +++ b/iOSClient/Utility/NCUtility+Image.swift @@ -193,6 +193,72 @@ extension NCUtility { return imageExt } + func createImageFileFrom(data: Data, ocId: String, etag: String, userId: String, urlBase: String) { + guard let image = UIImage(data: data) else { + return + } + let fileNamePath1024 = self.utilityFileSystem.getDirectoryProviderStorageImageOcId(ocId, + etag: etag, + ext: global.previewExt1024, + userId: userId, + urlBase: urlBase) + + do { + try data.write(to: URL(fileURLWithPath: fileNamePath1024), options: .atomic) + } catch { } + + createImageStandard(ocId: ocId, etag: etag, image: image, userId: userId, urlBase: urlBase) + } + + func createImageFileFrom(data: Data, ocId: String, etag: String) { + guard let image = UIImage(data: data) else { return } + let fileNamePath1024 = self.utilityFileSystem.getDirectoryProviderStorageImageOcId(ocId, etag: etag, ext: global.previewExt1024) + + do { + try data.write(to: URL(fileURLWithPath: fileNamePath1024), options: .atomic) + } catch { } + + createImageStandard(ocId: ocId, etag: etag, image: image) + } + + private func createImageStandard(ocId: String, etag: String, image: UIImage) { + let ext = [global.previewExt1024, global.previewExt512, global.previewExt256] + let size = [global.size1024, global.size512, global.size256] + let compressionQuality = [0.5, 0.6, 0.7] + + for i in 0.. UIImage? { + return UIImage(contentsOfFile: self.utilityFileSystem.getDirectoryProviderStorageImageOcId(ocId, etag: etag, ext: ext)) + } + func getImage(ocId: String, etag: String, ext: String, userId: String, urlBase: String) -> UIImage? { return UIImage(contentsOfFile: self.utilityFileSystem.getDirectoryProviderStorageImageOcId(ocId, etag: etag, ext: ext, userId: userId, urlBase: urlBase)) } diff --git a/iOSClient/Utility/NCUtilityFileSystem.swift b/iOSClient/Utility/NCUtilityFileSystem.swift index c687ac9edf..a30b2cdd80 100644 --- a/iOSClient/Utility/NCUtilityFileSystem.swift +++ b/iOSClient/Utility/NCUtilityFileSystem.swift @@ -295,6 +295,45 @@ final class NCUtilityFileSystem: NSObject, @unchecked Sendable { } return false } + + var directoryProviderStorage: String { + guard let directoryGroup = fileManager.containerURL(forSecurityApplicationGroupIdentifier: NCBrandOptions.shared.capabilitiesGroup) else { return "" } + let path = directoryGroup.appendingPathComponent(NCGlobal.shared.directoryProviderStorage).path + if !fileManager.fileExists(atPath: path) { + do { + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true) + } catch { print("Error: \(error)") } + } + return path + } + + func getDirectoryProviderStorageOcId(_ ocId: String) -> String { + let path = directoryProviderStorage + "/" + ocId + if !fileManager.fileExists(atPath: path) { + do { + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true) + } catch { print("Error: \(error)") } + } + return path + } + + func getDirectoryProviderStorageImageOcId(_ ocId: String, etag: String, ext: String) -> String { + return getDirectoryProviderStorageOcId(ocId) + "/" + etag + ext + } + + func fileProviderStorageImageExists(_ ocId: String, etag: String, ext: String) -> Bool { + let fileNamePath = getDirectoryProviderStorageImageOcId(ocId, etag: etag, ext: ext) + do { + let fileNamePathAttribute = try fileManager.attributesOfItem(atPath: fileNamePath) + let fileSize: UInt64 = fileNamePathAttribute[FileAttributeKey.size] as? UInt64 ?? 0 + if fileSize > 0 { + return true + } else { + return false + } + } catch { } + return false + } // MARK: - @@ -961,4 +1000,12 @@ final class NCUtilityFileSystem: NSObject, @unchecked Sendable { return String(Int(prefix) ?? 0) } + + func getTextServerUrl(session: NCSession.Session, serverUrl: String) -> String { + if let directory = NCManageDatabase.shared.getTableDirectory(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@", session.account, serverUrl)), let metadata = NCManageDatabase.shared.getMetadataFromOcId(directory.ocId) { + return (metadata.fileNameView) + } else { + return (serverUrl as NSString).lastPathComponent + } + } } From b41ccb2a31b9829a4aecbad16b95c7f03a60297c Mon Sep 17 00:00:00 2001 From: harshada-15-tsys Date: Thu, 1 Oct 2026 17:59:02 +0530 Subject: [PATCH 4/4] NMC 3733 - Albums on mobile app customisation localization changes for v11.0.0.9 Signed-off-by: harshada-15-tsys --- .../xcshareddata/swiftpm/Package.resolved | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0275536fe7..ef7ecccce6 100644 --- a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "state" : { - "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", - "version" : "3.6.1" + "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee", + "version" : "3.7.0" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", - "version" : "8.1.2" + "revision" : "cc31f5ed464b111f877ce8554cbef4455d410d91", + "version" : "8.1.4" } }, { @@ -235,15 +235,6 @@ "version" : "10.1.1" } }, - { - "identity" : "queuer", - "kind" : "remoteSourceControl", - "location" : "https://github.com/FabrizioBrancati/Queuer", - "state" : { - "revision" : "9ae00b0da125f127f1bbf2e92d8e1fbb0994427e", - "version" : "4.0.0" - } - }, { "identity" : "realm-core", "kind" : "remoteSourceControl", @@ -307,15 +298,6 @@ "version" : "1.8.1" } }, - { - "identity" : "swiftentrykit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/huri000/SwiftEntryKit", - "state" : { - "revision" : "5ad36cccf0c4b9fea32f4e9b17a8e38f07563ef0", - "version" : "2.0.0" - } - }, { "identity" : "swifter", "kind" : "remoteSourceControl",