diff --git a/Tuist/ProjectDescriptionHelpers/ExtensionTarget.swift b/Tuist/ProjectDescriptionHelpers/ExtensionTarget.swift index e980f412f..29445bbf3 100644 --- a/Tuist/ProjectDescriptionHelpers/ExtensionTarget.swift +++ b/Tuist/ProjectDescriptionHelpers/ExtensionTarget.swift @@ -68,6 +68,7 @@ public extension Target { "kDrive/UI/Controller/DriveUpdateRequiredViewController.swift", "kDrive/UI/Controller/FloatingPanelSelectOptionViewController.swift", "kDrive/UI/Controller/Create File/FloatingPanelUtils.swift", + "kDrive/UI/Controller/Create File/FloatingPanelLayouts.swift", "kDrive/UI/Controller/Files/Categories/**", "kDrive/UI/Controller/Files/Rights and Share/**", "kDrive/UI/Controller/Files/Save File/**", diff --git a/kDrive/AppDelegate.swift b/kDrive/AppDelegate.swift index b61288e25..3b9c5ef2d 100644 --- a/kDrive/AppDelegate.swift +++ b/kDrive/AppDelegate.swift @@ -103,6 +103,17 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } application.registerForRemoteNotifications() + // swiftlint:disable force_try + Task { + try! await Task.sleep(nanoseconds:5_000_000_000) + print("coucou") + let somePublicShare = URL(string: "https://kdrive.infomaniak.com/app/share/140946/01953831-16d3-4df6-8b48-33c8001c7981") + //await UIApplication.shared.open(somePublicShare!) // opens safari + + let components = URLComponents(url: somePublicShare!, resolvingAgainstBaseURL: true) + await UniversalLinksHelper.handlePath(components!.path) + } + return true } diff --git a/kDrive/AppRouter.swift b/kDrive/AppRouter.swift index 3b2f05caf..5bef6867c 100644 --- a/kDrive/AppRouter.swift +++ b/kDrive/AppRouter.swift @@ -284,7 +284,8 @@ public struct AppRouter: AppNavigable { let fileIds = sceneUserInfo[SceneRestorationValues.Carousel.filesIds.rawValue] as? [Int], let currentIndex = sceneUserInfo[SceneRestorationValues.Carousel.currentIndex.rawValue] as? Int, let normalFolderHierarchy = sceneUserInfo[SceneRestorationValues.Carousel.normalFolderHierarchy.rawValue] as? Bool, - let presentationOrigin = sceneUserInfo[SceneRestorationValues.Carousel.presentationOrigin.rawValue] as? PresentationOrigin else { + let presentationOrigin = + sceneUserInfo[SceneRestorationValues.Carousel.presentationOrigin.rawValue] as? PresentationOrigin else { Log.sceneDelegate("metadata issue for PreviewController :\(sceneUserInfo)", level: .error) return } @@ -584,6 +585,49 @@ public struct AppRouter: AppNavigable { // MARK: RouterFileNavigable + @MainActor public func presentPublicShare( + frozenRootFolder: File, + publicShareProxy: PublicShareProxy, + driveFileManager: DriveFileManager, + apiFetcher: PublicShareApiFetcher + ) { + guard let window, + let rootViewController = window.rootViewController else { + fatalError("TODO: lazy load a rootViewController") + } + + guard let rootViewController = window.rootViewController as? MainTabViewController else { + fatalError("Root is not a MainTabViewController") + return + } + + // TODO: Fix access right + guard !frozenRootFolder.isDisabled else { + fatalError("isDisabled") + return + } + + rootViewController.dismiss(animated: false) { + rootViewController.selectedIndex = MainTabBarIndex.files.rawValue + + guard let navigationController = rootViewController.selectedViewController as? UINavigationController else { + return + } + + let viewModel = PublicShareViewModel(publicShareProxy: publicShareProxy, + sortType: .nameAZ, + driveFileManager: driveFileManager, + currentDirectory: frozenRootFolder, + apiFetcher: apiFetcher) + let viewController = FileListViewController(viewModel: viewModel) + let publicShareNavigationController = UINavigationController(rootViewController: viewController) + publicShareNavigationController.modalPresentationStyle = .fullScreen + publicShareNavigationController.modalTransitionStyle = .coverVertical + + navigationController.present(publicShareNavigationController, animated: true, completion: nil) + } + } + @MainActor public func present(file: File, driveFileManager: DriveFileManager) { present(file: file, driveFileManager: driveFileManager, office: false) } diff --git a/kDrive/SceneDelegate.swift b/kDrive/SceneDelegate.swift index 6f23a8f33..25a69a2c9 100644 --- a/kDrive/SceneDelegate.swift +++ b/kDrive/SceneDelegate.swift @@ -230,13 +230,16 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, AccountManagerDel func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { Log.sceneDelegate("scene continue userActivity") - guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, - let incomingURL = userActivity.webpageURL, - let components = URLComponents(url: incomingURL, resolvingAgainstBaseURL: true) else { - return - } + Task { + guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let incomingURL = userActivity.webpageURL, + let components = URLComponents(url: incomingURL, resolvingAgainstBaseURL: true) else { + Log.sceneDelegate("scene continue userActivity - unable", level: .error) + return + } - UniversalLinksHelper.handlePath(components.path) + await UniversalLinksHelper.handlePath(components.path) + } } func scene(_ scene: UIScene, didFailToContinueUserActivityWithType userActivityType: String, error: Error) { diff --git a/kDrive/UI/Controller/Create File/FloatingPanelLayouts.swift b/kDrive/UI/Controller/Create File/FloatingPanelLayouts.swift new file mode 100644 index 000000000..1e1b5f92a --- /dev/null +++ b/kDrive/UI/Controller/Create File/FloatingPanelLayouts.swift @@ -0,0 +1,141 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import FloatingPanel +import kDriveCore +import kDriveResources +import UIKit + +/// Layout used for a folder within a public share +class PublicShareFolderFloatingPanelLayout: FloatingPanelLayout { + var position: FloatingPanelPosition = .bottom + var initialState: FloatingPanelState = .tip + var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] + private var backdropAlpha: CGFloat + + init(initialState: FloatingPanelState = .tip, hideTip: Bool = false, safeAreaInset: CGFloat = 0, backdropAlpha: CGFloat = 0) { + self.initialState = initialState + self.backdropAlpha = backdropAlpha + let extendedAnchor = FloatingPanelLayoutAnchor( + absoluteInset: 140.0 + safeAreaInset, + edge: .bottom, + referenceGuide: .superview + ) + anchors = [ + .full: extendedAnchor, + .half: extendedAnchor, + .tip: FloatingPanelLayoutAnchor(absoluteInset: 86.0 + safeAreaInset, edge: .bottom, referenceGuide: .superview) + ] + } + + func backdropAlpha(for state: FloatingPanelState) -> CGFloat { + return backdropAlpha + } +} + +/// Layout used for a file within a public share +class PublicShareFileFloatingPanelLayout: FloatingPanelLayout { + var position: FloatingPanelPosition = .bottom + var initialState: FloatingPanelState = .tip + var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] + private var backdropAlpha: CGFloat + + init(initialState: FloatingPanelState = .tip, hideTip: Bool = false, safeAreaInset: CGFloat = 0, backdropAlpha: CGFloat = 0) { + self.initialState = initialState + self.backdropAlpha = backdropAlpha + let extendedAnchor = FloatingPanelLayoutAnchor( + absoluteInset: 248.0 + safeAreaInset, + edge: .bottom, + referenceGuide: .superview + ) + anchors = [ + .full: extendedAnchor, + .half: extendedAnchor, + .tip: FloatingPanelLayoutAnchor(absoluteInset: 86.0 + safeAreaInset, edge: .bottom, referenceGuide: .superview) + ] + } + + func backdropAlpha(for state: FloatingPanelState) -> CGFloat { + return backdropAlpha + } +} + +class FileFloatingPanelLayout: FloatingPanelLayout { + var position: FloatingPanelPosition = .bottom + var initialState: FloatingPanelState = .tip + var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] + private var backdropAlpha: CGFloat + + init(initialState: FloatingPanelState = .tip, hideTip: Bool = false, safeAreaInset: CGFloat = 0, backdropAlpha: CGFloat = 0) { + self.initialState = initialState + self.backdropAlpha = backdropAlpha + if hideTip { + anchors = [ + .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea), + .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea) + ] + } else { + anchors = [ + .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea), + .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea), + .tip: FloatingPanelLayoutAnchor(absoluteInset: 86.0 + safeAreaInset, edge: .bottom, referenceGuide: .superview) + ] + } + } + + func backdropAlpha(for state: FloatingPanelState) -> CGFloat { + return backdropAlpha + } +} + +class PlusButtonFloatingPanelLayout: FloatingPanelLayout { + var position: FloatingPanelPosition = .bottom + var height: CGFloat = 16 + + init(height: CGFloat) { + self.height = height + } + + var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { + return [ + .full: FloatingPanelLayoutAnchor(absoluteInset: height, edge: .bottom, referenceGuide: .safeArea) + ] + } + + var initialState: FloatingPanelState = .full + + func backdropAlpha(for state: FloatingPanelState) -> CGFloat { + return 0.2 + } +} + +class InformationViewFloatingPanelLayout: FloatingPanelLayout { + var position: FloatingPanelPosition = .bottom + + var initialState: FloatingPanelState = .full + + var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { + return [ + .full: FloatingPanelIntrinsicLayoutAnchor(absoluteOffset: 0, referenceGuide: .safeArea) + ] + } + + func backdropAlpha(for state: FloatingPanelState) -> CGFloat { + return 0.3 + } +} diff --git a/kDrive/UI/Controller/Create File/FloatingPanelUtils.swift b/kDrive/UI/Controller/Create File/FloatingPanelUtils.swift index 793edb132..e5ca7bc58 100644 --- a/kDrive/UI/Controller/Create File/FloatingPanelUtils.swift +++ b/kDrive/UI/Controller/Create File/FloatingPanelUtils.swift @@ -73,68 +73,3 @@ class AdaptiveDriveFloatingPanelController: DriveFloatingPanelController { track(scrollView: scrollView) } } - -class FileFloatingPanelLayout: FloatingPanelLayout { - var position: FloatingPanelPosition = .bottom - var initialState: FloatingPanelState = .tip - var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] - private var backdropAlpha: CGFloat - - init(initialState: FloatingPanelState = .tip, hideTip: Bool = false, safeAreaInset: CGFloat = 0, backdropAlpha: CGFloat = 0) { - self.initialState = initialState - self.backdropAlpha = backdropAlpha - if hideTip { - anchors = [ - .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea), - .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea) - ] - } else { - anchors = [ - .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea), - .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea), - .tip: FloatingPanelLayoutAnchor(absoluteInset: 86.0 + safeAreaInset, edge: .bottom, referenceGuide: .superview) - ] - } - } - - func backdropAlpha(for state: FloatingPanelState) -> CGFloat { - return backdropAlpha - } -} - -class PlusButtonFloatingPanelLayout: FloatingPanelLayout { - var position: FloatingPanelPosition = .bottom - var height: CGFloat = 16 - - init(height: CGFloat) { - self.height = height - } - - var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { - return [ - .full: FloatingPanelLayoutAnchor(absoluteInset: height, edge: .bottom, referenceGuide: .safeArea) - ] - } - - var initialState: FloatingPanelState = .full - - func backdropAlpha(for state: FloatingPanelState) -> CGFloat { - return 0.2 - } -} - -class InformationViewFloatingPanelLayout: FloatingPanelLayout { - var position: FloatingPanelPosition = .bottom - - var initialState: FloatingPanelState = .full - - var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { - return [ - .full: FloatingPanelIntrinsicLayoutAnchor(absoluteOffset: 0, referenceGuide: .safeArea) - ] - } - - func backdropAlpha(for state: FloatingPanelState) -> CGFloat { - return 0.3 - } -} diff --git a/kDrive/UI/Controller/Files/File List/ConcreteFileListViewModel.swift b/kDrive/UI/Controller/Files/File List/ConcreteFileListViewModel.swift index 701e596fe..a5bd73668 100644 --- a/kDrive/UI/Controller/Files/File List/ConcreteFileListViewModel.swift +++ b/kDrive/UI/Controller/Files/File List/ConcreteFileListViewModel.swift @@ -59,13 +59,13 @@ class ConcreteFileListViewModel: FileListViewModel { try await loadFiles() } - override func barButtonPressed(type: FileListBarButtonType) { + override func barButtonPressed(sender: Any?, type: FileListBarButtonType) { if type == .search { let viewModel = SearchFilesViewModel(driveFileManager: driveFileManager) let searchViewController = SearchViewController.instantiateInNavigationController(viewModel: viewModel) onPresentViewController?(.modal, searchViewController, true) } else { - super.barButtonPressed(type: type) + super.barButtonPressed(sender: sender, type: type) } } } diff --git a/kDrive/UI/Controller/Files/File List/FileListViewController.swift b/kDrive/UI/Controller/Files/File List/FileListViewController.swift index 5ca4e7167..c9c8aca7a 100644 --- a/kDrive/UI/Controller/Files/File List/FileListViewController.swift +++ b/kDrive/UI/Controller/Files/File List/FileListViewController.swift @@ -19,6 +19,7 @@ import CocoaLumberjackSwift import Combine import DifferenceKit +import FloatingPanel import InfomaniakCore import InfomaniakDI import kDriveCore @@ -141,6 +142,7 @@ class FileListViewController: UICollectionViewController, SwipeActionCollectionV ) setupViewModel() + setupFooterIfNeeded() } override func viewWillAppear(_ animated: Bool) { @@ -250,6 +252,46 @@ class FileListViewController: UICollectionViewController, SwipeActionCollectionV } } + func setupFooterIfNeeded() { + guard driveFileManager.isPublicShare else { + return + } + + let addToKDriveButton = IKButton(type: .custom) + addToKDriveButton.setTitle("Add to My kDrive", for: .normal) + addToKDriveButton.addTarget(self, action: #selector(addToMyDriveButtonTapped), for: .touchUpInside) + addToKDriveButton.setBackgroundColors(normal: .systemBlue, highlighted: .darkGray) + addToKDriveButton.translatesAutoresizingMaskIntoConstraints = false + addToKDriveButton.cornerRadius = 8.0 + addToKDriveButton.clipsToBounds = true + + view.addSubview(addToKDriveButton) + view.bringSubviewToFront(addToKDriveButton) + + let leadingConstraint = addToKDriveButton.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, + constant: 16) + leadingConstraint.priority = .defaultHigh + let trailingConstraint = addToKDriveButton.trailingAnchor.constraint( + greaterThanOrEqualTo: view.trailingAnchor, + constant: -16 + ) + trailingConstraint.priority = .defaultHigh + let widthConstraint = addToKDriveButton.widthAnchor.constraint(lessThanOrEqualToConstant: 360) + + NSLayoutConstraint.activate([ + addToKDriveButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + leadingConstraint, + trailingConstraint, + addToKDriveButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), + addToKDriveButton.heightAnchor.constraint(equalToConstant: 60), + widthConstraint + ]) + } + + @objc func addToMyDriveButtonTapped() { + print("TODO: addToMyDriveButtonTapped") + } + func reloadCollectionViewWith(files: [File]) { let changeSet = StagedChangeset(source: displayedFiles, target: files) collectionView.reload(using: changeSet, @@ -348,6 +390,30 @@ class FileListViewController: UICollectionViewController, SwipeActionCollectionV } } + private func fileLayout(files: [File]) -> FloatingPanelLayout { + guard driveFileManager.isPublicShare else { + return FileFloatingPanelLayout( + initialState: .half, + hideTip: true, + backdropAlpha: 0.2 + ) + } + + if files.first?.isDirectory ?? false { + return PublicShareFolderFloatingPanelLayout( + initialState: .half, + hideTip: true, + backdropAlpha: 0.2 + ) + } else { + return PublicShareFileFloatingPanelLayout( + initialState: .half, + hideTip: true, + backdropAlpha: 0.2 + ) + } + } + private func showQuickActionsPanel(files: [File], actionType: FileListQuickActionType) { #if !ISEXTENSION var floatingPanelViewController: DriveFloatingPanelController @@ -359,11 +425,7 @@ class FileListViewController: UICollectionViewController, SwipeActionCollectionV fileInformationsViewController.presentingParent = self fileInformationsViewController.normalFolderHierarchy = viewModel.configuration.normalFolderHierarchy - floatingPanelViewController.layout = FileFloatingPanelLayout( - initialState: .half, - hideTip: true, - backdropAlpha: 0.2 - ) + floatingPanelViewController.layout = fileLayout(files: files) if let file = files.first { fileInformationsViewController.setFile(file, driveFileManager: driveFileManager) @@ -465,7 +527,7 @@ class FileListViewController: UICollectionViewController, SwipeActionCollectionV } @objc func barButtonPressed(_ sender: FileListBarButton) { - viewModel.barButtonPressed(type: sender.type) + viewModel.barButtonPressed(sender: sender, type: sender.type) } @objc func forceRefresh() { @@ -896,3 +958,38 @@ extension FileListViewController: UICollectionViewDropDelegate { } } } + +// Move to CoreUIKit or use something else ? +extension UIImage { + convenience init?(color: UIColor) { + let size = CGSize(width: 1, height: 1) + UIGraphicsBeginImageContext(size) + guard let context = UIGraphicsGetCurrentContext() else { + return nil + } + + context.setFillColor(color.cgColor) + context.fill(CGRect(origin: .zero, size: size)) + + let image = UIGraphicsGetImageFromCurrentImageContext()! + UIGraphicsEndImageContext() + guard let cgImage = image.cgImage else { + return nil + } + + self.init(cgImage: cgImage) + } +} + +// Move to CoreUIKit or use something else ? +extension IKButton { + func setBackgroundColors(normal normalColor: UIColor, highlighted highlightedColor: UIColor) { + if let normalImage = UIImage(color: normalColor) { + setBackgroundImage(normalImage, for: .normal) + } + + if let highlightedImage = UIImage(color: highlightedColor) { + setBackgroundImage(highlightedImage, for: .highlighted) + } + } +} diff --git a/kDrive/UI/Controller/Files/File List/FileListViewModel.swift b/kDrive/UI/Controller/Files/File List/FileListViewModel.swift index 6201676ef..cf1db00fc 100644 --- a/kDrive/UI/Controller/Files/File List/FileListViewModel.swift +++ b/kDrive/UI/Controller/Files/File List/FileListViewModel.swift @@ -34,6 +34,7 @@ enum FileListBarButtonType { case searchFilters case photoSort case addFolder + case downloadAll } enum FileListQuickActionType { @@ -279,7 +280,7 @@ class FileListViewModel: SelectDelegate { }.store(in: &bindStore) } - func barButtonPressed(type: FileListBarButtonType) { + func barButtonPressed(sender: Any? = nil, type: FileListBarButtonType) { if multipleSelectionViewModel?.isMultipleSelectionEnabled == true { multipleSelectionViewModel?.barButtonPressed(type: type) } @@ -342,7 +343,10 @@ class FileListViewModel: SelectDelegate { } func didSelectFile(at indexPath: IndexPath) { - guard let file: File = getFile(at: indexPath) else { return } + guard let file: File = getFile(at: indexPath) else { + return + } + if ReachabilityListener.instance.currentStatus == .offline && !file.isDirectory && !file.isAvailableOffline { return } diff --git a/kDrive/UI/Controller/Files/File List/MultipleSelectionFileListViewModel.swift b/kDrive/UI/Controller/Files/File List/MultipleSelectionFileListViewModel.swift index ea8257905..e8e854b7c 100644 --- a/kDrive/UI/Controller/Files/File List/MultipleSelectionFileListViewModel.swift +++ b/kDrive/UI/Controller/Files/File List/MultipleSelectionFileListViewModel.swift @@ -107,8 +107,15 @@ class MultipleSelectionFileListViewModel { init(configuration: FileListViewModel.Configuration, driveFileManager: DriveFileManager, currentDirectory: File) { isMultipleSelectionEnabled = false selectedCount = 0 - multipleSelectionActions = [.move, .delete, .more] + self.driveFileManager = driveFileManager + + if driveFileManager.isPublicShare { + multipleSelectionActions = [] + } else { + multipleSelectionActions = [.move, .delete, .more] + } + self.currentDirectory = currentDirectory self.configuration = configuration } diff --git a/kDrive/UI/Controller/Files/FileActionsFloatingPanelViewController+Actions.swift b/kDrive/UI/Controller/Files/FileActionsFloatingPanelViewController+Actions.swift index bbcf13c7c..4407c521c 100644 --- a/kDrive/UI/Controller/Files/FileActionsFloatingPanelViewController+Actions.swift +++ b/kDrive/UI/Controller/Files/FileActionsFloatingPanelViewController+Actions.swift @@ -36,7 +36,14 @@ extension FileActionsFloatingPanelViewController { private func setupQuickActions() { let offline = ReachabilityListener.instance.currentStatus == .offline - quickActions = file.isDirectory ? FloatingPanelAction.folderQuickActions : FloatingPanelAction.quickActions + if driveFileManager.isPublicShare { + quickActions = [] + } else if file.isDirectory { + quickActions = FloatingPanelAction.folderQuickActions + } else { + quickActions = FloatingPanelAction.quickActions + } + for action in quickActions { switch action { case .shareAndRights: @@ -58,6 +65,15 @@ extension FileActionsFloatingPanelViewController { } private func setupActions() { + guard !driveFileManager.isPublicShare else { + if file.isDirectory { + actions = FloatingPanelAction.publicShareFolderActions + } else { + actions = FloatingPanelAction.publicShareActions + } + return + } + actions = (file.isDirectory ? FloatingPanelAction.folderListActions : FloatingPanelAction.listActions).filter { action in switch action { case .openWith: @@ -175,7 +191,8 @@ extension FileActionsFloatingPanelViewController { if file.isMostRecentDownloaded { presentShareSheet(from: indexPath) } else { - downloadFile(action: action, indexPath: indexPath) { [weak self] in + downloadFile(action: action, + indexPath: indexPath) { [weak self] in self?.presentShareSheet(from: indexPath) } } diff --git a/kDrive/UI/Controller/Files/FileActionsFloatingPanelViewController.swift b/kDrive/UI/Controller/Files/FileActionsFloatingPanelViewController.swift index 4e1acdbb5..d462c2d3d 100644 --- a/kDrive/UI/Controller/Files/FileActionsFloatingPanelViewController.swift +++ b/kDrive/UI/Controller/Files/FileActionsFloatingPanelViewController.swift @@ -205,6 +205,14 @@ public class FloatingPanelAction: Equatable { return [informations, add, shareAndRights, shareLink].map { $0.reset() } } + static var publicShareActions: [FloatingPanelAction] { + return [openWith, sendCopy, download].map { $0.reset() } + } + + static var publicShareFolderActions: [FloatingPanelAction] { + return [download].map { $0.reset() } + } + static var multipleSelectionActions: [FloatingPanelAction] { return [manageCategories, favorite, offline, download, move, duplicate].map { $0.reset() } } @@ -388,7 +396,9 @@ final class FileActionsFloatingPanelViewController: UICollectionViewController { present(activityViewController, animated: true) } - func downloadFile(action: FloatingPanelAction, indexPath: IndexPath, completion: @escaping () -> Void) { + func downloadFile(action: FloatingPanelAction, + indexPath: IndexPath, + completion: @escaping () -> Void) { guard let observerViewController = UIApplication.shared.windows.first?.rootViewController else { return } downloadAction = action setLoading(true, action: action, at: indexPath) @@ -405,7 +415,15 @@ final class FileActionsFloatingPanelViewController: UICollectionViewController { } } } - DownloadQueue.instance.addToQueue(file: file, userId: accountManager.currentUserId) + + if let publicShareProxy = driveFileManager.publicShareProxy { + DownloadQueue.instance.addPublicShareToQueue(file: file, + driveFileManager: driveFileManager, + publicShareProxy: publicShareProxy) + } else { + DownloadQueue.instance.addToQueue(file: file, + userId: accountManager.currentUserId) + } } func copyShareLinkToPasteboard(from indexPath: IndexPath, link: String) { @@ -477,7 +495,17 @@ final class FileActionsFloatingPanelViewController: UICollectionViewController { case .actions: action = actions[indexPath.item] } - MatomoUtils.trackFileAction(action: action, file: file, fromPhotoList: presentingParent is PhotoListViewController) + + let eventCategory: MatomoUtils.EventCategory + if presentingParent is PhotoListViewController { + eventCategory = .picturesFileAction + } else if driveFileManager.isPublicShare { + eventCategory = .publicShareAction + } else { + eventCategory = .fileListFileAction + } + + MatomoUtils.trackFileAction(action: action, file: file, category: eventCategory) handleAction(action, at: indexPath) } } diff --git a/kDrive/UI/Controller/Files/FilePresenter.swift b/kDrive/UI/Controller/Files/FilePresenter.swift index 0d398e815..be01bcbeb 100644 --- a/kDrive/UI/Controller/Files/FilePresenter.swift +++ b/kDrive/UI/Controller/Files/FilePresenter.swift @@ -145,6 +145,12 @@ final class FilePresenter { let viewModel: FileListViewModel if driveFileManager.drive.sharedWithMe { viewModel = SharedWithMeViewModel(driveFileManager: driveFileManager, currentDirectory: file) + } else if let publicShareProxy = driveFileManager.publicShareProxy { + viewModel = PublicShareViewModel(publicShareProxy: publicShareProxy, + sortType: .nameAZ, + driveFileManager: driveFileManager, + currentDirectory: file, + apiFetcher: PublicShareApiFetcher()) } else if file.isTrashed || file.deletedAt != nil { viewModel = TrashListViewModel(driveFileManager: driveFileManager, currentDirectory: file) } else { diff --git a/kDrive/UI/Controller/Files/MultipleSelectionFloatingPanelViewController.swift b/kDrive/UI/Controller/Files/MultipleSelectionFloatingPanelViewController.swift index a22f6035a..a17b0aa76 100644 --- a/kDrive/UI/Controller/Files/MultipleSelectionFloatingPanelViewController.swift +++ b/kDrive/UI/Controller/Files/MultipleSelectionFloatingPanelViewController.swift @@ -209,6 +209,16 @@ final class MultipleSelectionFloatingPanelViewController: UICollectionViewContro override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let action = actions[indexPath.item] handleAction(action, at: indexPath) - MatomoUtils.trackBuklAction(action: action, files: files, fromPhotoList: presentingParent is PhotoListViewController) + + let eventCategory: MatomoUtils.EventCategory + if presentingParent is PhotoListViewController { + eventCategory = .picturesFileAction + } else if driveFileManager.isPublicShare { + eventCategory = .publicShareAction + } else { + eventCategory = .fileListFileAction + } + + MatomoUtils.trackBuklAction(action: action, files: files, category: eventCategory) } } diff --git a/kDrive/UI/Controller/Files/Preview/PreviewViewController.swift b/kDrive/UI/Controller/Files/Preview/PreviewViewController.swift index 19c0c6477..20b35ffa5 100644 --- a/kDrive/UI/Controller/Files/Preview/PreviewViewController.swift +++ b/kDrive/UI/Controller/Files/Preview/PreviewViewController.swift @@ -650,7 +650,11 @@ extension PreviewViewController: UICollectionViewDataSource { ) { let file = previewFiles[indexPath.row] if let cell = cell as? DownloadingPreviewCollectionViewCell { - cell.progressiveLoadingForFile(file) + if let publicShareProxy = driveFileManager.publicShareProxy { + cell.progressiveLoadingForPublicShareFile(file, publicShareProxy: publicShareProxy) + } else { + cell.progressiveLoadingForFile(file) + } } } diff --git a/kDrive/UI/Controller/Files/Search/SearchFilesViewModel.swift b/kDrive/UI/Controller/Files/Search/SearchFilesViewModel.swift index 99f975988..32d1acab3 100644 --- a/kDrive/UI/Controller/Files/Search/SearchFilesViewModel.swift +++ b/kDrive/UI/Controller/Files/Search/SearchFilesViewModel.swift @@ -84,7 +84,8 @@ class SearchFilesViewModel: FileListViewModel { filters = Filters() let searchFakeRoot = driveFileManager.getManagedFile(from: DriveFileManager.searchFilesRootFile) super.init(configuration: configuration, driveFileManager: driveFileManager, currentDirectory: searchFakeRoot) - observedFiles = AnyRealmCollection(AnyRealmCollection(searchFakeRoot.children).sorted(by: [sortType.value.sortDescriptor])) + observedFiles = AnyRealmCollection(AnyRealmCollection(searchFakeRoot.children) + .sorted(by: [sortType.value.sortDescriptor])) } override func startObservation() { @@ -145,16 +146,16 @@ class SearchFilesViewModel: FileListViewModel { private func searchOffline() { observedFiles = AnyRealmCollection(driveFileManager.searchOffline(query: currentSearchText, - date: filters.date?.dateInterval, - fileType: filters.fileType, - categories: Array(filters.categories), - fileExtensions: filters.fileExtensions, - belongToAllCategories: filters.belongToAllCategories, - sortType: sortType)) + date: filters.date?.dateInterval, + fileType: filters.fileType, + categories: Array(filters.categories), + fileExtensions: filters.fileExtensions, + belongToAllCategories: filters.belongToAllCategories, + sortType: sortType)) startObservation() } - override func barButtonPressed(type: FileListBarButtonType) { + override func barButtonPressed(sender: Any?, type: FileListBarButtonType) { if type == .searchFilters { let navigationController = SearchFiltersViewController .instantiateInNavigationController(driveFileManager: driveFileManager) @@ -163,7 +164,7 @@ class SearchFilesViewModel: FileListViewModel { searchFiltersViewController?.delegate = self onPresentViewController?(.modal, navigationController, true) } else { - super.barButtonPressed(type: type) + super.barButtonPressed(sender: sender, type: type) } } diff --git a/kDrive/UI/Controller/Menu/PhotoList/PhotoListViewModel.swift b/kDrive/UI/Controller/Menu/PhotoList/PhotoListViewModel.swift index 72d46e53e..d6378a65e 100644 --- a/kDrive/UI/Controller/Menu/PhotoList/PhotoListViewModel.swift +++ b/kDrive/UI/Controller/Menu/PhotoList/PhotoListViewModel.swift @@ -144,7 +144,7 @@ class PhotoListViewModel: FileListViewModel { self.nextCursor = nextCursor } - override func barButtonPressed(type: FileListBarButtonType) { + override func barButtonPressed(sender: Any?, type: FileListBarButtonType) { if type == .search { let viewModel = SearchFilesViewModel(driveFileManager: driveFileManager, filters: Filters(fileType: .image)) let searchViewController = SearchViewController.instantiateInNavigationController(viewModel: viewModel) @@ -156,7 +156,7 @@ class PhotoListViewModel: FileListViewModel { delegate: self) onPresentViewController?(.modal, floatingPanelViewController, true) } else { - super.barButtonPressed(type: type) + super.barButtonPressed(sender: sender, type: type) } } diff --git a/kDrive/UI/Controller/Menu/Share/PublicShareViewModel.swift b/kDrive/UI/Controller/Menu/Share/PublicShareViewModel.swift new file mode 100644 index 000000000..69273f79f --- /dev/null +++ b/kDrive/UI/Controller/Menu/Share/PublicShareViewModel.swift @@ -0,0 +1,141 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCore +import InfomaniakDI +import kDriveCore +import RealmSwift +import UIKit + +/// Public share view model, loading content from memory realm +final class PublicShareViewModel: InMemoryFileListViewModel { + private var downloadObserver: ObservationToken? + + var publicShareProxy: PublicShareProxy? + let rootProxy: ProxyFile + var publicShareApiFetcher: PublicShareApiFetcher? + + required init(driveFileManager: DriveFileManager, currentDirectory: File? = nil) { + guard let currentDirectory else { + fatalError("PublicShareViewModel requires a currentDirectory to work") + } + + // TODO: i18n + let configuration = Configuration(selectAllSupported: false, + rootTitle: "public share", + emptyViewType: .emptyFolder, + supportsDrop: false, + rightBarButtons: [.downloadAll], + matomoViewPath: [MatomoUtils.Views.menu.displayName, "publicShare"]) + + rootProxy = currentDirectory.proxify() + super.init(configuration: configuration, driveFileManager: driveFileManager, currentDirectory: currentDirectory) + observedFiles = AnyRealmCollection(currentDirectory.children) + } + + convenience init( + publicShareProxy: PublicShareProxy, + sortType: SortType, + driveFileManager: DriveFileManager, + currentDirectory: File, + apiFetcher: PublicShareApiFetcher + ) { + self.init(driveFileManager: driveFileManager, currentDirectory: currentDirectory) + self.publicShareProxy = publicShareProxy + self.sortType = sortType + publicShareApiFetcher = apiFetcher + } + + override func loadFiles(cursor: String? = nil, forceRefresh: Bool = false) async throws { + guard !isLoading || cursor != nil, + let publicShareProxy, + let publicShareApiFetcher else { + return + } + + // Only show loading indicator if we have nothing in cache + if !currentDirectory.canLoadChildrenFromCache { + startRefreshing(cursor: cursor) + } + defer { + endRefreshing() + } + + let (_, nextCursor) = try await driveFileManager.publicShareFiles(rootProxy: rootProxy, + publicShareProxy: publicShareProxy, + publicShareApiFetcher: publicShareApiFetcher) + endRefreshing() + if let nextCursor { + try await loadFiles(cursor: nextCursor) + } + } + + // TODO: Move away from view model + override func barButtonPressed(sender: Any?, type: FileListBarButtonType) { + guard downloadObserver == nil else { + return + } + + guard type == .downloadAll, + let publicShareProxy = publicShareProxy else { + return + } + + // TODO: Abstract sheet presentation + @InjectService var appNavigable: AppNavigable + guard let topMostViewController = appNavigable.topMostViewController else { + return + } + + downloadObserver = DownloadQueue.instance + .observeFileDownloaded(self, fileId: currentDirectory.id) { [weak self] _, error in + Task { @MainActor in + guard let self = self else { + return + } + + defer { + self.downloadObserver?.cancel() + self.downloadObserver = nil + } + + guard let senderItem = sender as? UIBarButtonItem else { + return + } + + guard error == nil else { + UIConstants.showSnackBarIfNeeded(error: DriveError.downloadFailed) + return + } + + // present share sheet + let activityViewController = UIActivityViewController( + activityItems: [self.currentDirectory.localUrl], + applicationActivities: nil + ) + + activityViewController.popoverPresentationController?.barButtonItem = senderItem + topMostViewController.present(activityViewController, animated: true) + } + } + + DownloadQueue.instance.addPublicShareToQueue(file: currentDirectory, + driveFileManager: driveFileManager, + publicShareProxy: publicShareProxy) + } +} diff --git a/kDrive/UI/Controller/Menu/SharedWithMe/SharedWithMeViewModel.swift b/kDrive/UI/Controller/Menu/Share/SharedWithMeViewModel.swift similarity index 100% rename from kDrive/UI/Controller/Menu/SharedWithMe/SharedWithMeViewModel.swift rename to kDrive/UI/Controller/Menu/Share/SharedWithMeViewModel.swift diff --git a/kDrive/UI/Controller/Menu/Trash/TrashListViewModel.swift b/kDrive/UI/Controller/Menu/Trash/TrashListViewModel.swift index 67ab3d778..e77094413 100644 --- a/kDrive/UI/Controller/Menu/Trash/TrashListViewModel.swift +++ b/kDrive/UI/Controller/Menu/Trash/TrashListViewModel.swift @@ -92,7 +92,7 @@ class TrashListViewModel: InMemoryFileListViewModel { forceRefresh() } - override func barButtonPressed(type: FileListBarButtonType) { + override func barButtonPressed(sender: Any?, type: FileListBarButtonType) { if type == .emptyTrash { let alert = AlertTextViewController(title: KDriveResourcesStrings.Localizable.modalEmptyTrashTitle, message: KDriveResourcesStrings.Localizable.modalEmptyTrashDescription, @@ -103,7 +103,7 @@ class TrashListViewModel: InMemoryFileListViewModel { } onPresentViewController?(.modal, alert, true) } else { - super.barButtonPressed(type: type) + super.barButtonPressed(sender: sender, type: type) } } diff --git a/kDrive/UI/View/Files/FileCollectionViewCell.swift b/kDrive/UI/View/Files/FileCollectionViewCell.swift index 23901692f..09e0b416b 100644 --- a/kDrive/UI/View/Files/FileCollectionViewCell.swift +++ b/kDrive/UI/View/Files/FileCollectionViewCell.swift @@ -48,6 +48,10 @@ protocol FileCellDelegate: AnyObject { var file: File var selectionMode: Bool var isSelected = false + + /// Public share data if file exists within a public share + let publicShareProxy: PublicShareProxy? + private var downloadProgressObserver: ObservationToken? private var downloadObserver: ObservationToken? var thumbnailDownloadTask: Kingfisher.DownloadTask? @@ -114,6 +118,7 @@ protocol FileCellDelegate: AnyObject { init(driveFileManager: DriveFileManager, file: File, selectionMode: Bool) { self.file = file self.selectionMode = selectionMode + publicShareProxy = driveFileManager.publicShareProxy categories = driveFileManager.drive.categories(for: file) } @@ -138,26 +143,52 @@ protocol FileCellDelegate: AnyObject { } func setThumbnail(on imageView: UIImageView) { + // check if public share / use specific endpoint guard !file.isInvalidated, - (file.convertedType == .image || file.convertedType == .video) && file.supportedBy.contains(.thumbnail) - else { return } + (file.convertedType == .image || file.convertedType == .video) && file.supportedBy.contains(.thumbnail) else { + return + } + // Configure placeholder imageView.image = nil imageView.contentMode = .scaleAspectFill imageView.layer.cornerRadius = UIConstants.imageCornerRadius imageView.layer.masksToBounds = true imageView.backgroundColor = KDriveResourcesAsset.loaderDefaultColor.color - // Fetch thumbnail - thumbnailDownloadTask = file.getThumbnail { [requestFileId = file.id, weak self] image, _ in - guard let self, - !self.file.isInvalidated, - !self.isSelected else { - return + + if let publicShareProxy = publicShareProxy { + // Fetch public share thumbnail + thumbnailDownloadTask = file.getPublicShareThumbnail(publicShareId: publicShareProxy.shareLinkUid, + publicDriveId: publicShareProxy.driveId, + publicFileId: file.id) { [ + requestFileId = file.id, + weak self + ] image, _ in + guard let self, + !self.file.isInvalidated, + !self.isSelected else { + return + } + + if file.id == requestFileId { + imageView.image = image + imageView.backgroundColor = nil + } } - if file.id == requestFileId { - imageView.image = image - imageView.backgroundColor = nil + } else { + // Fetch thumbnail + thumbnailDownloadTask = file.getThumbnail { [requestFileId = file.id, weak self] image, _ in + guard let self, + !self.file.isInvalidated, + !self.isSelected else { + return + } + + if file.id == requestFileId { + imageView.image = image + imageView.backgroundColor = nil + } } } } @@ -302,7 +333,7 @@ class FileCollectionViewCell: UICollectionViewCell, SwipableCell { func configure(with viewModel: FileViewModel) { self.viewModel = viewModel - configureLogoImage() + configureLogoImage(viewModel: viewModel) titleLabel.text = viewModel.title detailLabel?.text = viewModel.subtitle favoriteImageView?.isHidden = !viewModel.isFavorite @@ -321,7 +352,12 @@ class FileCollectionViewCell: UICollectionViewCell, SwipableCell { } func configureWith(driveFileManager: DriveFileManager, file: File, selectionMode: Bool = false) { - configure(with: FileViewModel(driveFileManager: driveFileManager, file: file, selectionMode: selectionMode)) + let fileViewModel = FileViewModel( + driveFileManager: driveFileManager, + file: file, + selectionMode: selectionMode + ) + configure(with: fileViewModel) } /// Update the cell selection mode. @@ -333,18 +369,20 @@ class FileCollectionViewCell: UICollectionViewCell, SwipableCell { } func configureForSelection() { - guard viewModel?.selectionMode == true else { return } + guard let viewModel, + viewModel.selectionMode == true else { + return + } if isSelected { configureCheckmarkImage() configureImport(shouldDisplay: false) } else { - configureLogoImage() + configureLogoImage(viewModel: viewModel) } } - private func configureLogoImage() { - guard let viewModel else { return } + private func configureLogoImage(viewModel: FileViewModel) { logoImage.isAccessibilityElement = true logoImage.accessibilityLabel = viewModel.iconAccessibilityLabel logoImage.image = viewModel.icon diff --git a/kDrive/UI/View/Files/FileGridCollectionViewCell.swift b/kDrive/UI/View/Files/FileGridCollectionViewCell.swift index 4ba0608a7..58b6e7ec2 100644 --- a/kDrive/UI/View/Files/FileGridCollectionViewCell.swift +++ b/kDrive/UI/View/Files/FileGridCollectionViewCell.swift @@ -110,7 +110,12 @@ class FileGridCollectionViewCell: FileCollectionViewCell { } override func configureWith(driveFileManager: DriveFileManager, file: File, selectionMode: Bool = false) { - configure(with: FileGridViewModel(driveFileManager: driveFileManager, file: file, selectionMode: selectionMode)) + let viewModel = FileGridViewModel( + driveFileManager: driveFileManager, + file: file, + selectionMode: selectionMode + ) + configure(with: viewModel) } override func configureLoading() { diff --git a/kDrive/UI/View/Files/FileListBarButton.swift b/kDrive/UI/View/Files/FileListBarButton.swift index c55363306..62984460f 100644 --- a/kDrive/UI/View/Files/FileListBarButton.swift +++ b/kDrive/UI/View/Files/FileListBarButton.swift @@ -17,8 +17,8 @@ */ import Foundation -import UIKit import kDriveResources +import UIKit final class FileListBarButton: UIBarButtonItem { private(set) var type: FileListBarButtonType = .cancel @@ -49,6 +49,10 @@ final class FileListBarButton: UIBarButtonItem { case .addFolder: self.init(image: KDriveResourcesAsset.folderAdd.image, style: .plain, target: target, action: action) accessibilityLabel = KDriveResourcesStrings.Localizable.createFolderTitle + case .downloadAll: + let image = KDriveResourcesAsset.download.image + self.init(image: image, style: .plain, target: target, action: action) + accessibilityLabel = KDriveResourcesStrings.Localizable.buttonDownload } self.type = type } diff --git a/kDrive/UI/View/Files/Preview/DownloadingPreviewCollectionViewCell.swift b/kDrive/UI/View/Files/Preview/DownloadingPreviewCollectionViewCell.swift index 18d434818..f26e027c0 100644 --- a/kDrive/UI/View/Files/Preview/DownloadingPreviewCollectionViewCell.swift +++ b/kDrive/UI/View/Files/Preview/DownloadingPreviewCollectionViewCell.swift @@ -97,6 +97,26 @@ class DownloadingPreviewCollectionViewCell: UICollectionViewCell, UIScrollViewDe return previewImageView } + func progressiveLoadingForPublicShareFile(_ file: File, publicShareProxy: PublicShareProxy) { + self.file = file + file.getPublicShareThumbnail(publicShareId: publicShareProxy.shareLinkUid, + publicDriveId: publicShareProxy.driveId, + publicFileId: file.id) { thumbnail, _ in + self.previewImageView.image = thumbnail + } + + previewDownloadTask = file.getPublicSharePreview(publicShareId: publicShareProxy.shareLinkUid, + publicDriveId: publicShareProxy.driveId, + publicFileId: file.id) { [weak previewImageView] preview in + guard let previewImageView else { + return + } + if let preview { + previewImageView.image = preview + } + } + } + func progressiveLoadingForFile(_ file: File) { self.file = file file.getThumbnail { thumbnail, _ in diff --git a/kDrive/Utils/MatomoUtils+UI.swift b/kDrive/Utils/MatomoUtils+UI.swift index e2a828f5c..88082c8ee 100644 --- a/kDrive/Utils/MatomoUtils+UI.swift +++ b/kDrive/Utils/MatomoUtils+UI.swift @@ -45,8 +45,7 @@ extension MatomoUtils { #if !ISEXTENSION - static func trackFileAction(action: FloatingPanelAction, file: File, fromPhotoList: Bool) { - let category: EventCategory = fromPhotoList ? .picturesFileAction : .fileListFileAction + static func trackFileAction(action: FloatingPanelAction, file: File, category: EventCategory) { switch action { // Quick Actions case .sendCopy: @@ -77,9 +76,8 @@ extension MatomoUtils { } } - static func trackBuklAction(action: FloatingPanelAction, files: [File], fromPhotoList: Bool) { + static func trackBuklAction(action: FloatingPanelAction, files: [File], category: EventCategory) { let numberOfFiles = files.count - let category: EventCategory = fromPhotoList ? .picturesFileAction : .fileListFileAction switch action { // Quick Actions case .duplicate: diff --git a/kDrive/Utils/UniversalLinksHelper.swift b/kDrive/Utils/UniversalLinksHelper.swift index 50b68adf5..10718c026 100644 --- a/kDrive/Utils/UniversalLinksHelper.swift +++ b/kDrive/Utils/UniversalLinksHelper.swift @@ -35,26 +35,44 @@ enum UniversalLinksHelper { regex: Regex(pattern: #"^/app/drive/([0-9]+)/redirect/([0-9]+)$"#)!, displayMode: .file ) + + /// Matches a public share link + static let publicShareLink = Link( + regex: Regex(pattern: #"^/app/share/([0-9]+)/([a-z0-9-]+)$"#)!, + displayMode: .file + ) + /// Matches a directory list link static let directoryLink = Link(regex: Regex(pattern: #"^/app/drive/([0-9]+)/files/([0-9]+)$"#)!, displayMode: .file) + /// Matches a file preview link static let filePreview = Link( regex: Regex(pattern: #"^/app/drive/([0-9]+)/files/([0-9]+/)?preview/[a-z]+/([0-9]+)$"#)!, displayMode: .file ) + /// Matches an office file link static let officeLink = Link(regex: Regex(pattern: #"^/app/office/([0-9]+)/([0-9]+)$"#)!, displayMode: .office) - static let all = [privateShareLink, directoryLink, filePreview, officeLink] + static let all = [privateShareLink, publicShareLink, directoryLink, filePreview, officeLink] } private enum DisplayMode { case office, file } - static func handlePath(_ path: String) -> Bool { + @discardableResult + static func handlePath(_ path: String) async -> Bool { DDLogInfo("[UniversalLinksHelper] Trying to open link with path: \(path)") + // Public share link regex + let shareLink = Link.publicShareLink + let matches = shareLink.regex.matches(in: path) + if await processPublicShareLink(matches: matches, displayMode: shareLink.displayMode) { + return true + } + + // Common regex for link in Link.all { let matches = link.regex.matches(in: path) if processRegex(matches: matches, displayMode: link.displayMode) { @@ -66,6 +84,47 @@ enum UniversalLinksHelper { return false } + private static func processPublicShareLink(matches: [[String]], displayMode: DisplayMode) async -> Bool { + @InjectService var accountManager: AccountManageable + + guard let firstMatch = matches.first, + let driveId = firstMatch[safe: 1], + let driveIdInt = Int(driveId), + let shareLinkUid = firstMatch[safe: 2] else { + return false + } + + // request metadata + let apiFetcher = PublicShareApiFetcher() + guard let metadata = try? await apiFetcher.getMetadata(driveId: driveIdInt, shareLinkUid: shareLinkUid) + else { + return false + } + + let trackerName: String + if metadata.isPasswordNeeded { + trackerName = "publicShareWithPassword" + } else if metadata.isExpired { + trackerName = "publicShareExpired" + } else { + trackerName = "publicShare" + } + MatomoUtils.trackDeeplink(name: trackerName) + + // get file ID from metadata + let publicShareDriveFileManager = accountManager.getInMemoryDriveFileManager( + for: shareLinkUid, + driveId: driveIdInt, + rootFileId: metadata.fileId + ) + openPublicShare(driveId: driveIdInt, + linkUuid: shareLinkUid, + fileId: metadata.fileId, + driveFileManager: publicShareDriveFileManager, + apiFetcher: apiFetcher) + return true + } + private static func processRegex(matches: [[String]], displayMode: DisplayMode) -> Bool { @InjectService var accountManager: AccountManageable @@ -83,6 +142,40 @@ enum UniversalLinksHelper { return true } + private static func openPublicShare(driveId: Int, + linkUuid: String, + fileId: Int, + driveFileManager: DriveFileManager, + apiFetcher: PublicShareApiFetcher) { + Task { + do { + let rootFolder = try await apiFetcher.getShareLinkFile(driveId: driveId, + linkUuid: linkUuid, + fileId: fileId) + // Root folder must be in database for the FileListViewModel to work + try driveFileManager.database.writeTransaction { writableRealm in + writableRealm.add(rootFolder) + } + + let frozenRootFolder = rootFolder.freeze() + + @InjectService var appNavigable: AppNavigable + let publicShareProxy = PublicShareProxy(driveId: driveId, fileId: fileId, shareLinkUid: linkUuid) + await appNavigable.presentPublicShare( + frozenRootFolder: frozenRootFolder, + publicShareProxy: publicShareProxy, + driveFileManager: driveFileManager, + apiFetcher: apiFetcher + ) + } catch { + DDLogError( + "[UniversalLinksHelper] Failed to get public folder [driveId:\(driveId) linkUuid:\(linkUuid) fileId:\(fileId)]: \(error)" + ) + await UIConstants.showSnackBarIfNeeded(error: error) + } + } + } + private static func openFile(id: Int, driveFileManager: DriveFileManager, office: Bool) { Task { do { diff --git a/kDriveCore/Data/Api/DriveApiFetcher.swift b/kDriveCore/Data/Api/DriveApiFetcher.swift index 0d75d0e84..ed27a7d51 100644 --- a/kDriveCore/Data/Api/DriveApiFetcher.swift +++ b/kDriveCore/Data/Api/DriveApiFetcher.swift @@ -52,6 +52,58 @@ public class AuthenticatedImageRequestModifier: ImageDownloadRequestModifier { } } +public struct PublicShareMetadata: Decodable { + public let url: URL + public let fileId: Int + public let right: String + + public let validUntil: TimeInterval? + public let capabilities: Rights + + // TODO: Test parsing + public let isPasswordNeeded: Bool = false + public let isExpired: Bool = false + + public let createdBy: TimeInterval + public let createdAt: TimeInterval + public let updatedAt: TimeInterval + public let accessBlocked: Bool + + enum CodingKeys: String, CodingKey { + case url + case fileId + case right + case validUntil + case capabilities + case createdBy + case createdAt + case updatedAt + case accessBlocked + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + do { + url = try container.decode(URL.self, forKey: .url) + fileId = try container.decode(Int.self, forKey: .fileId) + right = try container.decode(String.self, forKey: .right) + + validUntil = try container.decodeIfPresent(TimeInterval.self, forKey: .validUntil) + capabilities = try container.decode(Rights.self, forKey: .capabilities) + + createdBy = try container.decode(TimeInterval.self, forKey: .createdBy) + createdAt = try container.decode(TimeInterval.self, forKey: .createdAt) + updatedAt = try container.decode(TimeInterval.self, forKey: .updatedAt) + + accessBlocked = try container.decode(Bool.self, forKey: .accessBlocked) + } catch { + // TODO: remove + fatalError("error:\(error)") + } + } +} + public class DriveApiFetcher: ApiFetcher { @LazyInjectService var accountManager: AccountManageable @LazyInjectService var tokenable: InfomaniakTokenable diff --git a/kDriveCore/Data/Api/Endpoint+Files.swift b/kDriveCore/Data/Api/Endpoint+Files.swift new file mode 100644 index 000000000..e7bd35552 --- /dev/null +++ b/kDriveCore/Data/Api/Endpoint+Files.swift @@ -0,0 +1,314 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import InfomaniakCore +import RealmSwift + +// MARK: - Files + +public extension Endpoint { + // MARK: Dropbox + + static func dropboxes(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/dropboxes") + } + + static func dropbox(file: AbstractFile) -> Endpoint { + return .fileInfoV2(file).appending(path: "/dropbox", queryItems: [ + URLQueryItem(name: "with", value: "user,capabilities") + ]) + } + + static func dropboxInvite(file: AbstractFile) -> Endpoint { + return .dropbox(file: file).appending(path: "/invite") + } + + // MARK: Favorite + + static func favorites(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/favorites", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func favorite(file: AbstractFile) -> Endpoint { + return .fileInfoV2(file).appending(path: "/favorite") + } + + // MARK: File access + + static func invitation(drive: AbstractDrive, id: Int) -> Endpoint { + return .driveInfoV2(drive: drive).appending(path: "/files/invitations/\(id)") + } + + static func access(file: AbstractFile) -> Endpoint { + return .fileInfoV2(file).appending(path: "/access", queryItems: [ + URLQueryItem(name: "with", value: "user"), + noAvatarDefault() + ]) + } + + static func checkAccess(file: AbstractFile) -> Endpoint { + return .access(file: file).appending(path: "/check") + } + + static func invitationsAccess(file: AbstractFile) -> Endpoint { + return .access(file: file).appending(path: "/invitations") + } + + static func teamsAccess(file: AbstractFile) -> Endpoint { + return .access(file: file).appending(path: "/teams") + } + + static func teamAccess(file: AbstractFile, id: Int) -> Endpoint { + return .teamsAccess(file: file).appending(path: "/\(id)") + } + + static func usersAccess(file: AbstractFile) -> Endpoint { + return .access(file: file).appending(path: "/users") + } + + static func userAccess(file: AbstractFile, id: Int) -> Endpoint { + return .usersAccess(file: file).appending(path: "/\(id)") + } + + static func forceAccess(file: AbstractFile) -> Endpoint { + return .access(file: file).appending(path: "/force") + } + + // MARK: File permission + + static func acl(file: AbstractFile) -> Endpoint { + return .fileInfo(file).appending(path: "/acl") + } + + static func permissions(file: AbstractFile) -> Endpoint { + return .fileInfo(file).appending(path: "/permission") + } + + static func userPermission(file: AbstractFile) -> Endpoint { + return .permissions(file: file).appending(path: "/user") + } + + static func teamPermission(file: AbstractFile) -> Endpoint { + return .permissions(file: file).appending(path: "/team") + } + + static func inheritPermission(file: AbstractFile) -> Endpoint { + return .permissions(file: file).appending(path: "/inherit") + } + + static func permission(file: AbstractFile, id: Int) -> Endpoint { + return .permissions(file: file).appending(path: "/\(id)") + } + + // MARK: File version + + static func versions(file: AbstractFile) -> Endpoint { + return .fileInfo(file).appending(path: "/versions") + } + + static func version(file: AbstractFile, id: Int) -> Endpoint { + return .versions(file: file).appending(path: "/\(id)") + } + + static func downloadVersion(file: AbstractFile, id: Int) -> Endpoint { + return .version(file: file, id: id).appending(path: "/download") + } + + static func restoreVersion(file: AbstractFile, id: Int) -> Endpoint { + return .version(file: file, id: id).appending(path: "/restore") + } + + // MARK: File/directory + + static func file(_ file: AbstractFile) -> Endpoint { + return .driveInfo(drive: ProxyDrive(id: file.driveId)).appending(path: "/files/\(file.id)", + queryItems: [FileWith.fileExtra.toQueryItem()]) + } + + static func fileInfo(_ file: AbstractFile) -> Endpoint { + return .driveInfo(drive: ProxyDrive(id: file.driveId)).appending( + path: "/files/\(file.id)", + queryItems: [FileWith.fileExtra.toQueryItem(), noAvatarDefault()] + ) + } + + static func fileInfoV2(_ file: AbstractFile) -> Endpoint { + return .driveInfoV2(drive: ProxyDrive(id: file.driveId)).appending(path: "/files/\(file.id)", + queryItems: [FileWith.fileExtra.toQueryItem()]) + } + + static func files(of directory: AbstractFile) -> Endpoint { + return .fileInfo(directory).appending(path: "/files", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func createDirectory(in file: AbstractFile) -> Endpoint { + return .fileInfo(file).appending(path: "/directory", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func createFile(in file: AbstractFile) -> Endpoint { + return .fileInfo(file).appending(path: "/file", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func thumbnail(file: AbstractFile, at date: Date) -> Endpoint { + return .fileInfoV2(file).appending(path: "/thumbnail", queryItems: [ + URLQueryItem(name: "t", value: "\(Int(date.timeIntervalSince1970))") + ]) + } + + static func preview(file: AbstractFile, at date: Date) -> Endpoint { + return .fileInfoV2(file).appending(path: "/preview", queryItems: [ + URLQueryItem(name: "width", value: "2500"), + URLQueryItem(name: "height", value: "1500"), + URLQueryItem(name: "quality", value: "80"), + URLQueryItem(name: "t", value: "\(Int(date.timeIntervalSince1970))") + ]) + } + + static func download(file: AbstractFile, + publicShareProxy: PublicShareProxy? = nil, + as asType: String? = nil) -> Endpoint { + let queryItems: [URLQueryItem]? + if let asType { + queryItems = [URLQueryItem(name: "as", value: asType)] + } else { + queryItems = nil + } + if let publicShareProxy { + return .downloadShareLinkFile(driveId: publicShareProxy.driveId, + linkUuid: publicShareProxy.shareLinkUid, + fileId: file.id) + } else { + return .fileInfoV2(file).appending(path: "/download", queryItems: queryItems) + } + } + + static func convert(file: AbstractFile) -> Endpoint { + return .fileInfoV2(file).appending(path: "/convert", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func move(file: AbstractFile, destination: AbstractFile) -> Endpoint { + return .fileInfo(file).appending(path: "/move/\(destination.id)") + } + + static func duplicate(file: AbstractFile) -> Endpoint { + return .fileInfo(file).appending(path: "/duplicate", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func copy(file: AbstractFile, destination: AbstractFile) -> Endpoint { + return .fileInfo(file).appending(path: "/copy/\(destination.id)", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func rename(file: AbstractFile) -> Endpoint { + return .fileInfoV2(file).appending(path: "/rename", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func count(of directory: AbstractFile) -> Endpoint { + return .fileInfoV2(directory).appending(path: "/count") + } + + static func size(file: AbstractFile, depth: String) -> Endpoint { + return .fileInfo(file).appending(path: "/size", queryItems: [ + URLQueryItem(name: "depth", value: depth) + ]) + } + + static func unlock(file: AbstractFile) -> Endpoint { + return .fileInfo(file).appending(path: "/lock") + } + + static func directoryColor(file: AbstractFile) -> Endpoint { + return .fileInfoV2(file).appending(path: "/color") + } + + // MARK: Root directory + + static func lockedFiles(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/lock") + } + + static func rootFiles(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/1/files", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func bulkFiles(drive: AbstractDrive) -> Endpoint { + return .driveInfoV2(drive: drive).appending(path: "/files/bulk") + } + + static func lastModifiedFiles(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/last_modified", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func largestFiles(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/largest") + } + + static func mostVersionedFiles(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/most_versions") + } + + static func countByTypeFiles(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/file_types") + } + + static func createTeamDirectory(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/team_directory", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func existFiles(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/exists") + } + + static func sharedFiles(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/shared") + } + + static func mySharedFiles(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending( + path: "/files/my_shared", + queryItems: [(FileWith.fileMinimal + [.users]).toQueryItem(), noAvatarDefault()] + ) + } + + static func sharedWithMeFiles(drive: AbstractDrive) -> Endpoint { + return .driveV3.appending(path: "/files/shared_with_me", + queryItems: [(FileWith.fileMinimal).toQueryItem()]) + } + + static func countInRoot(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/files/count") + } + + // MARK: Listing + + static func fileListing(file: AbstractFile) -> Endpoint { + return .fileInfo(file).appending(path: "/listing", queryItems: [FileWith.fileListingMinimal.toQueryItem()]) + } + + static func fileListingContinue(file: AbstractFile, cursor: String) -> Endpoint { + return .fileInfo(file).appending(path: "/listing/continue", queryItems: [URLQueryItem(name: "cursor", value: cursor), + FileWith.fileListingMinimal.toQueryItem()]) + } + + static func filePartialListing(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending( + path: "/files/listing/partial", + queryItems: [URLQueryItem(name: "with", value: "file")] + ) + } +} diff --git a/kDriveCore/Data/Api/Endpoint+Share.swift b/kDriveCore/Data/Api/Endpoint+Share.swift new file mode 100644 index 000000000..2d3d01edd --- /dev/null +++ b/kDriveCore/Data/Api/Endpoint+Share.swift @@ -0,0 +1,95 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import InfomaniakCore +import RealmSwift + +// MARK: - Share Links + +public extension Endpoint { + /// It is necessary to keep V1 here for backward compatibility of old links + static var shareUrlV1: Endpoint { + return Endpoint(hostKeypath: \.driveHost, path: "/app") + } + + static var shareUrlV2: Endpoint { + return Endpoint(hostKeypath: \.driveHost, path: "/2/app") + } + + static var shareUrlV3: Endpoint { + return Endpoint(hostKeypath: \.driveHost, path: "/3/app") + } + + static func shareLinkFiles(drive: AbstractDrive) -> Endpoint { + return .driveInfoV2(drive: drive).appending(path: "/files/links") + } + + static func shareLink(file: AbstractFile) -> Endpoint { + return .fileInfoV2(file).appending(path: "/link") + } + + /// Share link info + static func shareLinkInfo(driveId: Int, shareLinkUid: String) -> Endpoint { + shareUrlV2.appending(path: "/\(driveId)/share/\(shareLinkUid)/init") + } + + /// Share link file + static func shareLinkFile(driveId: Int, linkUuid: String, fileId: Int) -> Endpoint { + shareUrlV3.appending(path: "/\(driveId)/share/\(linkUuid)/files/\(fileId)") + } + + /// Some legacy calls like thumbnails require a V2 call + static func shareLinkFileV2(driveId: Int, linkUuid: String, fileId: Int) -> Endpoint { + shareUrlV2.appending(path: "/\(driveId)/share/\(linkUuid)/files/\(fileId)") + } + + /// Share link file children + static func shareLinkFileChildren(driveId: Int, linkUuid: String, fileId: Int, sortType: SortType) -> Endpoint { + let orderByQuery = URLQueryItem(name: "order_by", value: sortType.value.apiValue) + let orderQuery = URLQueryItem(name: "order", value: sortType.value.order) + let withQuery = URLQueryItem(name: "with", value: "capabilities,conversion_capabilities,supported_by") + + let shareLinkQueryItems = [orderByQuery, orderQuery, withQuery] + let fileChildrenEndpoint = Self.shareUrlV3.appending(path: "/\(driveId)/share/\(linkUuid)/files/\(fileId)/files") + return fileChildrenEndpoint.appending(path: "", queryItems: shareLinkQueryItems) + } + + /// Share link file thumbnail + static func shareLinkFileThumbnail(driveId: Int, linkUuid: String, fileId: Int) -> Endpoint { + return shareLinkFileV2(driveId: driveId, linkUuid: linkUuid, fileId: fileId).appending(path: "/thumbnail") + } + + /// Share link file preview + static func shareLinkFilePreview(driveId: Int, linkUuid: String, fileId: Int) -> Endpoint { + return shareLinkFileV2(driveId: driveId, linkUuid: linkUuid, fileId: fileId).appending(path: "/preview") + } + + /// Download share link file + static func downloadShareLinkFile(driveId: Int, linkUuid: String, fileId: Int) -> Endpoint { + return shareLinkFileV2(driveId: driveId, linkUuid: linkUuid, fileId: fileId).appending(path: "/download") + } + + func showOfficeShareLinkFile(driveId: Int, linkUuid: String, fileId: Int) -> Endpoint { + return Self.shareUrlV1.appending(path: "/share/\(driveId)/\(linkUuid)/preview/text/\(fileId)") + } + + func importShareLinkFiles(driveId: Int) -> Endpoint { + return Self.shareUrlV2.appending(path: "/\(driveId)/imports/sharelink") + } +} diff --git a/kDriveCore/Data/Api/Endpoint+Trash.swift b/kDriveCore/Data/Api/Endpoint+Trash.swift new file mode 100644 index 000000000..d90a1a6d6 --- /dev/null +++ b/kDriveCore/Data/Api/Endpoint+Trash.swift @@ -0,0 +1,70 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import InfomaniakCore +import RealmSwift + +// MARK: - Trash + +public extension Endpoint { + static func trash(drive: AbstractDrive) -> Endpoint { + return .driveInfo(drive: drive).appending(path: "/trash", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func trashV2(drive: AbstractDrive) -> Endpoint { + return .driveInfoV2(drive: drive).appending(path: "/trash") + } + + static func emptyTrash(drive: AbstractDrive) -> Endpoint { + return .driveInfoV2(drive: drive).appending(path: "/trash") + } + + static func trashCount(drive: AbstractDrive) -> Endpoint { + return .trash(drive: drive).appending(path: "/count") + } + + static func trashedInfo(file: AbstractFile) -> Endpoint { + return .trash(drive: ProxyDrive(id: file.driveId)).appending( + path: "/\(file.id)", + queryItems: [FileWith.fileExtra.toQueryItem(), noAvatarDefault()] + ) + } + + static func trashedInfoV2(file: AbstractFile) -> Endpoint { + return .trashV2(drive: ProxyDrive(id: file.driveId)).appending(path: "/\(file.id)") + } + + static func trashedFiles(of directory: AbstractFile) -> Endpoint { + return .trashedInfo(file: directory).appending(path: "/files", queryItems: [FileWith.fileMinimal.toQueryItem()]) + } + + static func restore(file: AbstractFile) -> Endpoint { + return .trashedInfoV2(file: file).appending(path: "/restore") + } + + static func trashThumbnail(file: AbstractFile, at date: Date) -> Endpoint { + return .trashedInfoV2(file: file).appending(path: "/thumbnail", queryItems: [ + URLQueryItem(name: "t", value: "\(Int(date.timeIntervalSince1970))") + ]) + } + + static func trashCount(of directory: AbstractFile) -> Endpoint { + return .trashedInfo(file: directory).appending(path: "/count") + } +} diff --git a/kDriveCore/Data/Api/Endpoint.swift b/kDriveCore/Data/Api/Endpoint.swift index dd93b038a..9485a410d 100644 --- a/kDriveCore/Data/Api/Endpoint.swift +++ b/kDriveCore/Data/Api/Endpoint.swift @@ -167,7 +167,7 @@ extension File: AbstractFile {} // MARK: - Endpoints public extension Endpoint { - private static var driveV3: Endpoint { + static var driveV3: Endpoint { return Endpoint(hostKeypath: \.apiDriveHost, path: "/3/drive") } @@ -195,24 +195,6 @@ public extension Endpoint { return .driveInfoV2(drive: drive).appending(path: "/cancel") } - // MARK: Listing - - static func fileListing(file: AbstractFile) -> Endpoint { - return .fileInfo(file).appending(path: "/listing", queryItems: [FileWith.fileListingMinimal.toQueryItem()]) - } - - static func fileListingContinue(file: AbstractFile, cursor: String) -> Endpoint { - return .fileInfo(file).appending(path: "/listing/continue", queryItems: [URLQueryItem(name: "cursor", value: cursor), - FileWith.fileListingMinimal.toQueryItem()]) - } - - static func filePartialListing(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending( - path: "/files/listing/partial", - queryItems: [URLQueryItem(name: "with", value: "file")] - ) - } - // MARK: Activities static func recentActivity(drive: AbstractDrive) -> Endpoint { @@ -310,211 +292,6 @@ public extension Endpoint { return .driveInfo(drive: drive).appending(path: "/settings") } - // MARK: Dropbox - - static func dropboxes(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/dropboxes") - } - - static func dropbox(file: AbstractFile) -> Endpoint { - return .fileInfoV2(file).appending(path: "/dropbox", queryItems: [ - URLQueryItem(name: "with", value: "user,capabilities") - ]) - } - - static func dropboxInvite(file: AbstractFile) -> Endpoint { - return .dropbox(file: file).appending(path: "/invite") - } - - // MARK: Favorite - - static func favorites(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/favorites", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func favorite(file: AbstractFile) -> Endpoint { - return .fileInfoV2(file).appending(path: "/favorite") - } - - // MARK: File access - - static func invitation(drive: AbstractDrive, id: Int) -> Endpoint { - return .driveInfoV2(drive: drive).appending(path: "/files/invitations/\(id)") - } - - static func access(file: AbstractFile) -> Endpoint { - return .fileInfoV2(file).appending(path: "/access", queryItems: [ - URLQueryItem(name: "with", value: "user"), - noAvatarDefault() - ]) - } - - static func checkAccess(file: AbstractFile) -> Endpoint { - return .access(file: file).appending(path: "/check") - } - - static func invitationsAccess(file: AbstractFile) -> Endpoint { - return .access(file: file).appending(path: "/invitations") - } - - static func teamsAccess(file: AbstractFile) -> Endpoint { - return .access(file: file).appending(path: "/teams") - } - - static func teamAccess(file: AbstractFile, id: Int) -> Endpoint { - return .teamsAccess(file: file).appending(path: "/\(id)") - } - - static func usersAccess(file: AbstractFile) -> Endpoint { - return .access(file: file).appending(path: "/users") - } - - static func userAccess(file: AbstractFile, id: Int) -> Endpoint { - return .usersAccess(file: file).appending(path: "/\(id)") - } - - static func forceAccess(file: AbstractFile) -> Endpoint { - return .access(file: file).appending(path: "/force") - } - - // MARK: File permission - - static func acl(file: AbstractFile) -> Endpoint { - return .fileInfo(file).appending(path: "/acl") - } - - static func permissions(file: AbstractFile) -> Endpoint { - return .fileInfo(file).appending(path: "/permission") - } - - static func userPermission(file: AbstractFile) -> Endpoint { - return .permissions(file: file).appending(path: "/user") - } - - static func teamPermission(file: AbstractFile) -> Endpoint { - return .permissions(file: file).appending(path: "/team") - } - - static func inheritPermission(file: AbstractFile) -> Endpoint { - return .permissions(file: file).appending(path: "/inherit") - } - - static func permission(file: AbstractFile, id: Int) -> Endpoint { - return .permissions(file: file).appending(path: "/\(id)") - } - - // MARK: File version - - static func versions(file: AbstractFile) -> Endpoint { - return .fileInfo(file).appending(path: "/versions") - } - - static func version(file: AbstractFile, id: Int) -> Endpoint { - return .versions(file: file).appending(path: "/\(id)") - } - - static func downloadVersion(file: AbstractFile, id: Int) -> Endpoint { - return .version(file: file, id: id).appending(path: "/download") - } - - static func restoreVersion(file: AbstractFile, id: Int) -> Endpoint { - return .version(file: file, id: id).appending(path: "/restore") - } - - // MARK: File/directory - - static func file(_ file: AbstractFile) -> Endpoint { - return .driveInfo(drive: ProxyDrive(id: file.driveId)).appending(path: "/files/\(file.id)", - queryItems: [FileWith.fileExtra.toQueryItem()]) - } - - static func fileInfo(_ file: AbstractFile) -> Endpoint { - return .driveInfo(drive: ProxyDrive(id: file.driveId)).appending( - path: "/files/\(file.id)", - queryItems: [FileWith.fileExtra.toQueryItem(), noAvatarDefault()] - ) - } - - static func fileInfoV2(_ file: AbstractFile) -> Endpoint { - return .driveInfoV2(drive: ProxyDrive(id: file.driveId)).appending(path: "/files/\(file.id)", - queryItems: [FileWith.fileExtra.toQueryItem()]) - } - - static func files(of directory: AbstractFile) -> Endpoint { - return .fileInfo(directory).appending(path: "/files", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func createDirectory(in file: AbstractFile) -> Endpoint { - return .fileInfo(file).appending(path: "/directory", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func createFile(in file: AbstractFile) -> Endpoint { - return .fileInfo(file).appending(path: "/file", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func thumbnail(file: AbstractFile, at date: Date) -> Endpoint { - return .fileInfoV2(file).appending(path: "/thumbnail", queryItems: [ - URLQueryItem(name: "t", value: "\(Int(date.timeIntervalSince1970))") - ]) - } - - static func preview(file: AbstractFile, at date: Date) -> Endpoint { - return .fileInfoV2(file).appending(path: "/preview", queryItems: [ - URLQueryItem(name: "width", value: "2500"), - URLQueryItem(name: "height", value: "1500"), - URLQueryItem(name: "quality", value: "80"), - URLQueryItem(name: "t", value: "\(Int(date.timeIntervalSince1970))") - ]) - } - - static func download(file: AbstractFile, as asType: String? = nil) -> Endpoint { - let queryItems: [URLQueryItem]? - if let asType { - queryItems = [URLQueryItem(name: "as", value: asType)] - } else { - queryItems = nil - } - return .fileInfoV2(file).appending(path: "/download", queryItems: queryItems) - } - - static func convert(file: AbstractFile) -> Endpoint { - return .fileInfoV2(file).appending(path: "/convert", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func move(file: AbstractFile, destination: AbstractFile) -> Endpoint { - return .fileInfo(file).appending(path: "/move/\(destination.id)") - } - - static func duplicate(file: AbstractFile) -> Endpoint { - return .fileInfo(file).appending(path: "/duplicate", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func copy(file: AbstractFile, destination: AbstractFile) -> Endpoint { - return .fileInfo(file).appending(path: "/copy/\(destination.id)", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func rename(file: AbstractFile) -> Endpoint { - return .fileInfoV2(file).appending(path: "/rename", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func count(of directory: AbstractFile) -> Endpoint { - return .fileInfoV2(directory).appending(path: "/count") - } - - static func size(file: AbstractFile, depth: String) -> Endpoint { - return .fileInfo(file).appending(path: "/size", queryItems: [ - URLQueryItem(name: "depth", value: depth) - ]) - } - - static func unlock(file: AbstractFile) -> Endpoint { - return .fileInfo(file).appending(path: "/lock") - } - - static func directoryColor(file: AbstractFile) -> Endpoint { - return .fileInfoV2(file).appending(path: "/color") - } - // MARK: - Import static func cancelImport(drive: AbstractDrive, id: Int) -> Endpoint { @@ -531,64 +308,6 @@ public extension Endpoint { return .driveInfo(drive: drive).appending(path: "/preference") } - // MARK: Root directory - - static func lockedFiles(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/lock") - } - - static func rootFiles(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/1/files", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func bulkFiles(drive: AbstractDrive) -> Endpoint { - return .driveInfoV2(drive: drive).appending(path: "/files/bulk") - } - - static func lastModifiedFiles(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/last_modified", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func largestFiles(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/largest") - } - - static func mostVersionedFiles(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/most_versions") - } - - static func countByTypeFiles(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/file_types") - } - - static func createTeamDirectory(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/team_directory", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func existFiles(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/exists") - } - - static func sharedFiles(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/shared") - } - - static func mySharedFiles(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending( - path: "/files/my_shared", - queryItems: [(FileWith.fileMinimal + [.users]).toQueryItem(), noAvatarDefault()] - ) - } - - static func sharedWithMeFiles(drive: AbstractDrive) -> Endpoint { - return .driveV3.appending(path: "/files/shared_with_me", - queryItems: [(FileWith.fileMinimal).toQueryItem()]) - } - - static func countInRoot(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/files/count") - } - // MARK: Search static func search( @@ -626,63 +345,6 @@ public extension Endpoint { return .driveInfo(drive: drive).appending(path: "/files/search", queryItems: queryItems) } - // MARK: Share link - - static func shareLinkFiles(drive: AbstractDrive) -> Endpoint { - return .driveInfoV2(drive: drive).appending(path: "/files/links") - } - - static func shareLink(file: AbstractFile) -> Endpoint { - return .fileInfoV2(file).appending(path: "/link") - } - - // MARK: Trash - - static func trash(drive: AbstractDrive) -> Endpoint { - return .driveInfo(drive: drive).appending(path: "/trash", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func trashV2(drive: AbstractDrive) -> Endpoint { - return .driveInfoV2(drive: drive).appending(path: "/trash") - } - - static func emptyTrash(drive: AbstractDrive) -> Endpoint { - return .driveInfoV2(drive: drive).appending(path: "/trash") - } - - static func trashCount(drive: AbstractDrive) -> Endpoint { - return .trash(drive: drive).appending(path: "/count") - } - - static func trashedInfo(file: AbstractFile) -> Endpoint { - return .trash(drive: ProxyDrive(id: file.driveId)).appending( - path: "/\(file.id)", - queryItems: [FileWith.fileExtra.toQueryItem(), noAvatarDefault()] - ) - } - - static func trashedInfoV2(file: AbstractFile) -> Endpoint { - return .trashV2(drive: ProxyDrive(id: file.driveId)).appending(path: "/\(file.id)") - } - - static func trashedFiles(of directory: AbstractFile) -> Endpoint { - return .trashedInfo(file: directory).appending(path: "/files", queryItems: [FileWith.fileMinimal.toQueryItem()]) - } - - static func restore(file: AbstractFile) -> Endpoint { - return .trashedInfoV2(file: file).appending(path: "/restore") - } - - static func trashThumbnail(file: AbstractFile, at date: Date) -> Endpoint { - return .trashedInfoV2(file: file).appending(path: "/thumbnail", queryItems: [ - URLQueryItem(name: "t", value: "\(Int(date.timeIntervalSince1970))") - ]) - } - - static func trashCount(of directory: AbstractFile) -> Endpoint { - return .trashedInfo(file: directory).appending(path: "/count") - } - // MARK: Upload // Direct Upload diff --git a/kDriveCore/Data/Api/PublicShareApiFetcher.swift b/kDriveCore/Data/Api/PublicShareApiFetcher.swift new file mode 100644 index 000000000..341b22741 --- /dev/null +++ b/kDriveCore/Data/Api/PublicShareApiFetcher.swift @@ -0,0 +1,67 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Alamofire +import InfomaniakCore +import InfomaniakDI +import InfomaniakLogin +import Kingfisher + +public class PublicShareApiFetcher: ApiFetcher { + override public init() { + super.init() + } + + public func getMetadata(driveId: Int, shareLinkUid: String) async throws -> PublicShareMetadata { + let shareLinkInfoUrl = Endpoint.shareLinkInfo(driveId: driveId, shareLinkUid: shareLinkUid).url + // TODO: Use authenticated token if availlable + let request = Session.default.request(shareLinkInfoUrl) + let metadata: PublicShareMetadata = try await perform(request: request) + return metadata + } + + public func getShareLinkFile(driveId: Int, linkUuid: String, fileId: Int) async throws -> File { + let shareLinkFileUrl = Endpoint.shareLinkFile(driveId: driveId, linkUuid: linkUuid, fileId: fileId).url + let requestParameters: [String: String] = [ + APIUploadParameter.with.rawValue: FileWith.capabilities.rawValue + ] + let request = Session.default.request(shareLinkFileUrl, parameters: requestParameters) + let shareLinkFile: File = try await perform(request: request) + return shareLinkFile + } + + /// Query a specific page + public func shareLinkFileChildren(rootFolderId: Int, + publicShareProxy: PublicShareProxy, + sortType: SortType, + cursor: String? = nil) async throws -> ValidServerResponse<[File]> { + let shareLinkFileChildren = Endpoint.shareLinkFileChildren( + driveId: publicShareProxy.driveId, + linkUuid: publicShareProxy.shareLinkUid, + fileId: rootFolderId, + sortType: sortType + ) + .cursored(cursor) + .sorted(by: [sortType]) + + let shareLinkFileChildrenUrl = shareLinkFileChildren.url + let request = Session.default.request(shareLinkFileChildrenUrl) + let shareLinkFiles: ValidServerResponse<[File]> = try await perform(request: request) + return shareLinkFiles + } +} diff --git a/kDriveCore/Data/Cache/AccountManager.swift b/kDriveCore/Data/Cache/AccountManager.swift index 5e780e683..53ee93823 100644 --- a/kDriveCore/Data/Cache/AccountManager.swift +++ b/kDriveCore/Data/Cache/AccountManager.swift @@ -24,6 +24,19 @@ import InfomaniakLogin import RealmSwift import Sentry +// TODO: Delete +public class SomeRefreshTokenDelegate: RefreshTokenDelegate { + public init() {} + + public func didUpdateToken(newToken: ApiToken, oldToken: ApiToken) { + print("noop") + } + + public func didFailRefreshToken(_ token: ApiToken) { + print("noop") + } +} + public protocol UpdateAccountDelegate: AnyObject { @MainActor func didUpdateCurrentAccountInformations(_ currentAccount: Account) } @@ -63,6 +76,9 @@ public protocol AccountManageable: AnyObject { func reloadTokensAndAccounts() func getDriveFileManager(for driveId: Int, userId: Int) -> DriveFileManager? func getFirstAvailableDriveFileManager(for userId: Int) throws -> DriveFileManager + + /// Create on the fly an "in memory" DriveFileManager for a specific share + func getInMemoryDriveFileManager(for publicShareId: String, driveId: Int, rootFileId: Int) -> DriveFileManager func getApiFetcher(for userId: Int, token: ApiToken) -> DriveApiFetcher func getTokenForUserId(_ id: Int) -> ApiToken? func didUpdateToken(newToken: ApiToken, oldToken: ApiToken) @@ -194,6 +210,34 @@ public class AccountManager: RefreshTokenDelegate, AccountManageable { } } + public func getInMemoryDriveFileManager(for publicShareId: String, driveId: Int, rootFileId: Int) -> DriveFileManager { + if let inMemoryDriveFileManager = driveFileManagers[publicShareId] { + return inMemoryDriveFileManager + } + + // TODO: Big hack, refactor to allow for non authenticated requests + guard let someToken = apiFetchers.values.first?.currentToken else { + fatalError("probably no account available") + } + + // FileViewModel K.O. without a valid drive in Realm, therefore add one + let publicShareDrive = Drive() + publicShareDrive.objectId = publicShareId + @LazyInjectService var driveInfosManager: DriveInfosManager + do { + try driveInfosManager.storePublicShareDrive(drive: publicShareDrive) + } catch { + fatalError("unable to update public share drive in base, \(error)") + } + let forzenPublicShareDrive = publicShareDrive.freeze() + + let apiFetcher = DriveApiFetcher(token: someToken, delegate: SomeRefreshTokenDelegate()) + let publicShareProxy = PublicShareProxy(driveId: driveId, fileId: rootFileId, shareLinkUid: publicShareId) + let context = DriveFileManagerContext.publicShare(shareProxy: publicShareProxy) + + return DriveFileManager(drive: forzenPublicShareDrive, apiFetcher: apiFetcher, context: context) + } + public func getFirstAvailableDriveFileManager(for userId: Int) throws -> DriveFileManager { let userDrives = driveInfosManager.getDrives(for: userId) diff --git a/kDriveCore/Data/Cache/DriveFileManager/DriveFileManager+Transactionable.swift b/kDriveCore/Data/Cache/DriveFileManager/DriveFileManager+Transactionable.swift index 8133f6d0b..bf1be04df 100644 --- a/kDriveCore/Data/Cache/DriveFileManager/DriveFileManager+Transactionable.swift +++ b/kDriveCore/Data/Cache/DriveFileManager/DriveFileManager+Transactionable.swift @@ -29,7 +29,10 @@ public enum DriveFileManagerContext { /// Dedicated dataset to store shared with me case sharedWithMe - func realmURL(driveId: Int, driveUserId: Int) -> URL { + /// Dedicated in memory dataset for a public share link + case publicShare(shareProxy: PublicShareProxy) + + func realmURL(driveId: Int, driveUserId: Int) -> URL? { switch self { case .drive: return DriveFileManager.constants.realmRootURL.appendingPathComponent("\(driveUserId)-\(driveId).realm") @@ -37,6 +40,9 @@ public enum DriveFileManagerContext { return DriveFileManager.constants.realmRootURL.appendingPathComponent("\(driveUserId)-shared.realm") case .fileProvider: return DriveFileManager.constants.realmRootURL.appendingPathComponent("\(driveUserId)-\(driveId)-fp.realm") + case .publicShare: + // Public share are stored in memory only + return nil } } } diff --git a/kDriveCore/Data/Cache/DriveFileManager/DriveFileManager.swift b/kDriveCore/Data/Cache/DriveFileManager/DriveFileManager.swift index c7a25ee92..c924e5704 100644 --- a/kDriveCore/Data/Cache/DriveFileManager/DriveFileManager.swift +++ b/kDriveCore/Data/Cache/DriveFileManager/DriveFileManager.swift @@ -26,6 +26,21 @@ import InfomaniakLogin import RealmSwift import SwiftRegex +// TODO: Move to core +extension TransactionExecutor: CustomStringConvertible { + public var description: String { + var render = "TransactionExecutor: realm access issue" + try? writeTransaction { realm in + render = """ + TransactionExecutor: + realmURL:\(realm.configuration.fileURL) + inMemory:\(realm.configuration.inMemoryIdentifier) + """ + } + return render + } +} + // MARK: - Transactionable public final class DriveFileManager { @@ -84,11 +99,23 @@ public final class DriveFileManager { /// Fetch and write into DB with this object public let database: Transactionable + /// Context this object was initialized with + public let context: DriveFileManagerContext + /// Build a realm configuration for a specific Drive public static func configuration(context: DriveFileManagerContext, driveId: Int, driveUserId: Int) -> Realm.Configuration { let realmURL = context.realmURL(driveId: driveId, driveUserId: driveUserId) + + let inMemoryIdentifier: String? + if case .publicShare(let identifier) = context { + inMemoryIdentifier = "inMemory:\(identifier)" + } else { + inMemoryIdentifier = nil + } + return Realm.Configuration( fileURL: realmURL, + inMemoryIdentifier: inMemoryIdentifier, schemaVersion: RealmSchemaVersion.drive, migrationBlock: { migration, oldSchemaVersion in let currentDriveSchemeVersion = RealmSchemaVersion.drive @@ -191,9 +218,28 @@ public final class DriveFileManager { ) } + public var isPublicShare: Bool { + switch context { + case .publicShare: + return true + default: + return false + } + } + + public var publicShareProxy: PublicShareProxy? { + switch context { + case .publicShare(let shareProxy): + return shareProxy + default: + return nil + } + } + init(drive: Drive, apiFetcher: DriveApiFetcher, context: DriveFileManagerContext = .drive) { self.drive = drive self.apiFetcher = apiFetcher + self.context = context realmConfiguration = Self.configuration(context: context, driveId: drive.id, driveUserId: drive.userId) let realmURL = context.realmURL(driveId: drive.id, driveUserId: drive.userId) @@ -383,6 +429,28 @@ public final class DriveFileManager { forceRefresh: forceRefresh) } + public func publicShareFiles(rootProxy: ProxyFile, + publicShareProxy: PublicShareProxy, + cursor: String? = nil, + sortType: SortType = .nameAZ, + forceRefresh: Bool = false, + publicShareApiFetcher: PublicShareApiFetcher) async throws + -> (files: [File], nextCursor: String?) { + try await files(in: rootProxy, + fetchFiles: { + let mySharedFiles = try await publicShareApiFetcher.shareLinkFileChildren( + rootFolderId: rootProxy.id, + publicShareProxy: publicShareProxy, + sortType: sortType + ) + return mySharedFiles + }, + cursor: cursor, + sortType: sortType, + keepProperties: [.standard, .path, .version], + forceRefresh: forceRefresh) + } + public func searchFile(query: String? = nil, date: DateInterval? = nil, fileType: ConvertedType? = nil, diff --git a/kDriveCore/Data/Cache/DriveFileManager/DriveFileManagerConstants.swift b/kDriveCore/Data/Cache/DriveFileManager/DriveFileManagerConstants.swift index 9ce515899..b65c3f6d2 100644 --- a/kDriveCore/Data/Cache/DriveFileManager/DriveFileManagerConstants.swift +++ b/kDriveCore/Data/Cache/DriveFileManager/DriveFileManagerConstants.swift @@ -28,7 +28,7 @@ public enum RealmSchemaVersion { static let upload: UInt64 = 21 /// Current version of the Drive Realm - static let drive: UInt64 = 11 + static let drive: UInt64 = 12 } public class DriveFileManagerConstants { diff --git a/kDriveCore/Data/Cache/DriveInfosManager/DriveInfosManager.swift b/kDriveCore/Data/Cache/DriveInfosManager/DriveInfosManager.swift index 744bf2232..aeee624b1 100644 --- a/kDriveCore/Data/Cache/DriveInfosManager/DriveInfosManager.swift +++ b/kDriveCore/Data/Cache/DriveInfosManager/DriveInfosManager.swift @@ -134,6 +134,14 @@ public final class DriveInfosManager: DriveInfosManagerQueryable { drive.sharedWithMe = sharedWithMe } + // TODO: Add a flag that this drive can be cleaned + /// Store a specific public share Drive in realm for use by FileListViewControllers + public func storePublicShareDrive(drive: Drive) throws { + try driveInfoDatabase.writeTransaction { writableRealm in + writableRealm.add(drive, update: .modified) + } + } + @discardableResult func storeDriveResponse(user: InfomaniakCore.UserProfile, driveResponse: DriveResponse) -> [Drive] { var driveList = [Drive]() diff --git a/kDriveCore/Data/DownloadQueue/DownloadOperation.swift b/kDriveCore/Data/DownloadQueue/DownloadOperation.swift index 47f9a226e..2002c47cc 100644 --- a/kDriveCore/Data/DownloadQueue/DownloadOperation.swift +++ b/kDriveCore/Data/DownloadQueue/DownloadOperation.swift @@ -38,6 +38,7 @@ public class DownloadOperation: Operation, DownloadOperationable { private let fileManager = FileManager.default private let driveFileManager: DriveFileManager private let urlSession: FileDownloadSession + private let publicShareProxy: PublicShareProxy? private let itemIdentifier: NSFileProviderItemIdentifier? private var progressObservation: NSKeyValueObservation? private var backgroundTaskIdentifier: UIBackgroundTaskIdentifier = .invalid @@ -93,19 +94,25 @@ public class DownloadOperation: Operation, DownloadOperationable { file: File, driveFileManager: DriveFileManager, urlSession: FileDownloadSession, + publicShareProxy: PublicShareProxy? = nil, itemIdentifier: NSFileProviderItemIdentifier? = nil ) { self.file = File(value: file) self.driveFileManager = driveFileManager self.urlSession = urlSession + self.publicShareProxy = publicShareProxy self.itemIdentifier = itemIdentifier } - public init(file: File, driveFileManager: DriveFileManager, task: URLSessionDownloadTask, urlSession: FileDownloadSession) { + public init(file: File, + driveFileManager: DriveFileManager, + task: URLSessionDownloadTask, + urlSession: FileDownloadSession) { self.file = file self.driveFileManager = driveFileManager self.urlSession = urlSession self.task = task + publicShareProxy = nil itemIdentifier = nil } @@ -170,6 +177,53 @@ public class DownloadOperation: Operation, DownloadOperationable { } override public func main() { + DDLogInfo("[DownloadOperation] Start for \(file.id) with session \(urlSession.identifier)") + + if let publicShareProxy { + downloadPublicShareFile(publicShareProxy: publicShareProxy) + } else { + downloadFile() + } + } + + private func downloadPublicShareFile(publicShareProxy: PublicShareProxy) { + DDLogInfo("[DownloadOperation] Downloading publicShare \(file.id) with session \(urlSession.identifier)") + + let url = Endpoint.download(file: file, publicShareProxy: publicShareProxy).url + + // Add download task to Realm + let downloadTask = DownloadTask( + fileId: file.id, + isDirectory: file.isDirectory, + driveId: file.driveId, + userId: driveFileManager.drive.userId, + sessionId: urlSession.identifier, + sessionUrl: url.absoluteString + ) + + try? uploadsDatabase.writeTransaction { writableRealm in + writableRealm.add(downloadTask, update: .modified) + } + + let request = URLRequest(url: url) + task = urlSession.downloadTask(with: request, completionHandler: downloadCompletion) + progressObservation = task?.progress.observe(\.fractionCompleted, options: .new) { [fileId = file.id] _, value in + guard let newValue = value.newValue else { + return + } + DownloadQueue.instance.publishProgress(newValue, for: fileId) + } + if let itemIdentifier { + driveInfosManager.getFileProviderManager(for: driveFileManager.drive) { manager in + manager.register(self.task!, forItemWithIdentifier: itemIdentifier) { _ in + // META: keep SonarCloud happy + } + } + } + task?.resume() + } + + private func downloadFile() { DDLogInfo("[DownloadOperation] Downloading \(file.id) with session \(urlSession.identifier)") let url = Endpoint.download(file: file).url @@ -207,7 +261,7 @@ public class DownloadOperation: Operation, DownloadOperationable { } task?.resume() } else { - error = .localError // Other error? + error = .unknownToken // Other error? end(sessionUrl: url) } } @@ -288,7 +342,7 @@ public class DownloadOperation: Operation, DownloadOperationable { return } - assert(file.isDownloaded, "Expecting to be downloaded at the end of the downloadOperation") + assert(file.isDownloaded, "Expecting to be downloaded at the end of the downloadOperation error:\(error)") try? uploadsDatabase.writeTransaction { writableRealm in guard let task = writableRealm.objects(DownloadTask.self) diff --git a/kDriveCore/Data/DownloadQueue/DownloadQueue.swift b/kDriveCore/Data/DownloadQueue/DownloadQueue.swift index a0f7450b6..db2eaf452 100644 --- a/kDriveCore/Data/DownloadQueue/DownloadQueue.swift +++ b/kDriveCore/Data/DownloadQueue/DownloadQueue.swift @@ -112,6 +112,40 @@ public final class DownloadQueue: ParallelismHeuristicDelegate { // MARK: - Public methods + public func addPublicShareToQueue(file: File, + driveFileManager: DriveFileManager, + publicShareProxy: PublicShareProxy, + itemIdentifier: NSFileProviderItemIdentifier? = nil) { + Log.downloadQueue("addPublicShareToQueue file:\(file.id)") + let file = file.freezeIfNeeded() + + dispatchQueue.async { + guard !self.hasOperation(for: file.id) else { + Log.downloadQueue("Already in download queue, skipping \(file.id)", level: .error) + return + } + + OperationQueueHelper.disableIdleTimer(true) + + let operation = DownloadOperation( + file: file, + driveFileManager: driveFileManager, + urlSession: self.bestSession, + publicShareProxy: publicShareProxy, + itemIdentifier: itemIdentifier + ) + operation.completionBlock = { + self.dispatchQueue.async { + self.operationsInQueue.removeValue(forKey: file.id) + self.publishFileDownloaded(fileId: file.id, error: operation.error) + OperationQueueHelper.disableIdleTimer(false, hasOperationsInQueue: !self.operationsInQueue.isEmpty) + } + } + self.operationQueue.addOperation(operation) + self.operationsInQueue[file.id] = operation + } + } + public func addToQueue(file: File, userId: Int, itemIdentifier: NSFileProviderItemIdentifier? = nil) { diff --git a/kDriveCore/Data/Models/Drive/Drive.swift b/kDriveCore/Data/Models/Drive/Drive.swift index c6ff0c515..d6d8bbef9 100644 --- a/kDriveCore/Data/Models/Drive/Drive.swift +++ b/kDriveCore/Data/Models/Drive/Drive.swift @@ -190,8 +190,9 @@ public final class Drive: Object, Codable { // File is not managed by Realm: cannot use the `.sorted(by:)` method :( fileCategoriesIds = file.categories.sorted { $0.addedAt.compare($1.addedAt) == .orderedAscending }.map(\.categoryId) } - let filteredCategories = categories.filter(NSPredicate(format: "id IN %@", fileCategoriesIds)) + // Sort the categories + let filteredCategories = categories.filter("id IN %@", fileCategoriesIds) return fileCategoriesIds.compactMap { id in filteredCategories.first { $0.id == id } } } diff --git a/kDriveCore/Data/Models/File+Image.swift b/kDriveCore/Data/Models/File+Image.swift index 87fab6c8e..e7a07e627 100644 --- a/kDriveCore/Data/Models/File+Image.swift +++ b/kDriveCore/Data/Models/File+Image.swift @@ -16,10 +16,41 @@ along with this program. If not, see . */ +import InfomaniakCore import Kingfisher import UIKit public extension File { + /// Get a Thumbnail for a file from a public share + @discardableResult + func getPublicShareThumbnail(publicShareId: String, + publicDriveId: Int, + publicFileId: Int, + completion: @escaping ((UIImage, Bool) -> Void)) -> Kingfisher.DownloadTask? { + guard supportedBy.contains(.thumbnail) else { + completion(icon, false) + return nil + } + + let thumbnailURL = Endpoint.shareLinkFileThumbnail(driveId: publicDriveId, + linkUuid: publicShareId, + fileId: publicFileId).url + + return KingfisherManager.shared.retrieveImage(with: thumbnailURL) { result in + if let image = try? result.get().image { + completion(image, true) + } else { + // The file can become invalidated while retrieving the icon online + completion( + self.isInvalidated ? ConvertedType.unknown.icon : self + .icon, + false + ) + } + } + } + + /// Get a Thumbnail for a file for the current DriveFileManager @discardableResult func getThumbnail(completion: @escaping ((UIImage, Bool) -> Void)) -> Kingfisher.DownloadTask? { if supportedBy.contains(.thumbnail), let currentDriveFileManager = accountManager.currentDriveFileManager { @@ -40,22 +71,40 @@ public extension File { } @discardableResult - func getPreview(completion: @escaping ((UIImage?) -> Void)) -> Kingfisher.DownloadTask? { - if let currentDriveFileManager = accountManager.currentDriveFileManager { - return KingfisherManager.shared.retrieveImage(with: imagePreviewUrl, - options: [ - .requestModifier(currentDriveFileManager.apiFetcher - .authenticatedKF), - .preloadAllAnimationData - ]) { result in - if let image = try? result.get().image { - completion(image) - } else { - completion(nil) - } + func getPublicSharePreview(publicShareId: String, + publicDriveId: Int, + publicFileId: Int, + completion: @escaping ((UIImage?) -> Void)) -> Kingfisher.DownloadTask? { + let previewURL = Endpoint.shareLinkFilePreview(driveId: publicDriveId, + linkUuid: publicShareId, + fileId: publicFileId).url + + return KingfisherManager.shared.retrieveImage(with: previewURL) { result in + if let image = try? result.get().image { + completion(image) + } else { + completion(nil) } - } else { + } + } + + @discardableResult + func getPreview(completion: @escaping ((UIImage?) -> Void)) -> Kingfisher.DownloadTask? { + guard let currentDriveFileManager = accountManager.currentDriveFileManager else { return nil } + + return KingfisherManager.shared.retrieveImage(with: imagePreviewUrl, + options: [ + .requestModifier(currentDriveFileManager.apiFetcher + .authenticatedKF), + .preloadAllAnimationData + ]) { result in + if let image = try? result.get().image { + completion(image) + } else { + completion(nil) + } + } } } diff --git a/kDriveCore/Data/Models/File.swift b/kDriveCore/Data/Models/File.swift index ffdc71d8c..79c36649d 100644 --- a/kDriveCore/Data/Models/File.swift +++ b/kDriveCore/Data/Models/File.swift @@ -182,6 +182,19 @@ public enum ConvertedType: String, CaseIterable { public static let ignoreThumbnailTypes = downloadableTypes } +/// Minimal data needed to query a PublicShare +public struct PublicShareProxy { + public let driveId: Int + public let fileId: Int + public let shareLinkUid: String + + public init(driveId: Int, fileId: Int, shareLinkUid: String) { + self.driveId = driveId + self.fileId = fileId + self.shareLinkUid = shareLinkUid + } +} + public enum SortType: String { case nameAZ case nameZA @@ -541,7 +554,8 @@ public final class File: Object, Codable { public var isDownloaded: Bool { let localPath = localUrl.path - guard fileManager.fileExists(atPath: localPath) else { + let temporaryPath = temporaryUrl.path + guard fileManager.fileExists(atPath: localPath) || fileManager.fileExists(atPath: temporaryPath) else { DDLogError("[File] no local copy to read from") return false } diff --git a/kDriveCore/Data/Models/Rights.swift b/kDriveCore/Data/Models/Rights.swift index 6d599a289..322db9750 100644 --- a/kDriveCore/Data/Models/Rights.swift +++ b/kDriveCore/Data/Models/Rights.swift @@ -44,7 +44,8 @@ public class Rights: EmbeddedObject, Codable { /// Right to use and give team access @Persisted public var canUseTeam: Bool - // Directory capabilities + // MARK: Directory capabilities + /// Right to add new child directory @Persisted public var canCreateDirectory: Bool /// Right to add new child file @@ -56,6 +57,21 @@ public class Rights: EmbeddedObject, Codable { /// Right to use convert directory into dropbox @Persisted public var canBecomeDropbox: Bool + // MARK: Public share + + /// Can edit + @Persisted public var canEdit: Bool + /// Can see stats + @Persisted public var canSeeStats: Bool + /// Can see info + @Persisted public var canSeeInfo: Bool + /// Can download + @Persisted public var canDownload: Bool + /// Can comment + @Persisted public var canComment: Bool + /// Can request access + @Persisted public var canRequestAccess: Bool + enum CodingKeys: String, CodingKey { case canShow case canRead @@ -73,26 +89,38 @@ public class Rights: EmbeddedObject, Codable { case canUpload case canMoveInto case canBecomeDropbox + case canEdit + case canSeeStats + case canSeeInfo + case canDownload + case canComment + case canRequestAccess } public required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - canShow = try container.decode(Bool.self, forKey: .canShow) - canRead = try container.decode(Bool.self, forKey: .canRead) - canWrite = try container.decode(Bool.self, forKey: .canWrite) - canShare = try container.decode(Bool.self, forKey: .canShare) - canLeave = try container.decode(Bool.self, forKey: .canLeave) - canDelete = try container.decode(Bool.self, forKey: .canDelete) - canRename = try container.decode(Bool.self, forKey: .canRename) - canMove = try container.decode(Bool.self, forKey: .canMove) - canBecomeSharelink = try container.decode(Bool.self, forKey: .canBecomeSharelink) - canUseFavorite = try container.decode(Bool.self, forKey: .canUseFavorite) - canUseTeam = try container.decode(Bool.self, forKey: .canUseTeam) + canShow = try container.decodeIfPresent(Bool.self, forKey: .canShow) ?? true + canRead = try container.decodeIfPresent(Bool.self, forKey: .canRead) ?? true + canWrite = try container.decodeIfPresent(Bool.self, forKey: .canWrite) ?? false + canShare = try container.decodeIfPresent(Bool.self, forKey: .canShare) ?? false + canLeave = try container.decodeIfPresent(Bool.self, forKey: .canLeave) ?? false + canDelete = try container.decodeIfPresent(Bool.self, forKey: .canDelete) ?? false + canRename = try container.decodeIfPresent(Bool.self, forKey: .canRename) ?? false + canMove = try container.decodeIfPresent(Bool.self, forKey: .canMove) ?? false + canBecomeSharelink = try container.decodeIfPresent(Bool.self, forKey: .canBecomeSharelink) ?? false + canUseFavorite = try container.decodeIfPresent(Bool.self, forKey: .canUseFavorite) ?? false + canUseTeam = try container.decodeIfPresent(Bool.self, forKey: .canUseTeam) ?? false canCreateDirectory = try container.decodeIfPresent(Bool.self, forKey: .canCreateDirectory) ?? false canCreateFile = try container.decodeIfPresent(Bool.self, forKey: .canCreateFile) ?? false canUpload = try container.decodeIfPresent(Bool.self, forKey: .canUpload) ?? false canMoveInto = try container.decodeIfPresent(Bool.self, forKey: .canMoveInto) ?? false canBecomeDropbox = try container.decodeIfPresent(Bool.self, forKey: .canBecomeDropbox) ?? false + canEdit = try container.decodeIfPresent(Bool.self, forKey: .canEdit) ?? false + canSeeStats = try container.decodeIfPresent(Bool.self, forKey: .canSeeStats) ?? false + canSeeInfo = try container.decodeIfPresent(Bool.self, forKey: .canSeeInfo) ?? false + canDownload = try container.decodeIfPresent(Bool.self, forKey: .canDownload) ?? false + canComment = try container.decodeIfPresent(Bool.self, forKey: .canComment) ?? false + canRequestAccess = try container.decodeIfPresent(Bool.self, forKey: .canRequestAccess) ?? false } override public init() { diff --git a/kDriveCore/Utils/AppNavigable.swift b/kDriveCore/Utils/AppNavigable.swift index dc54ce51a..bcd52b59e 100644 --- a/kDriveCore/Utils/AppNavigable.swift +++ b/kDriveCore/Utils/AppNavigable.swift @@ -64,6 +64,14 @@ public protocol RouterFileNavigable { /// - office: Open in only office @MainActor func present(file: File, driveFileManager: DriveFileManager, office: Bool) + /// Present a file list for a public share, regardless of authenticated state + @MainActor func presentPublicShare( + frozenRootFolder: File, + publicShareProxy: PublicShareProxy, + driveFileManager: DriveFileManager, + apiFetcher: PublicShareApiFetcher + ) + /// Present a list of files from a folder /// - Parameters: /// - frozenFolder: Folder to display diff --git a/kDriveCore/Utils/DeeplinkParser.swift b/kDriveCore/Utils/DeeplinkParser.swift index 1af20238b..80ab6d180 100644 --- a/kDriveCore/Utils/DeeplinkParser.swift +++ b/kDriveCore/Utils/DeeplinkParser.swift @@ -17,6 +17,7 @@ */ import InfomaniakDI +import MatomoTracker import SwiftUI /// Deeplink entrypoint @@ -50,6 +51,7 @@ public struct DeeplinkParser: DeeplinkParsable { let driveId = params.first(where: { $0.name == "driveId" })?.value, let driveIdInt = Int(driveId), let userIdInt = Int(userId) { await router.navigate(to: .store(driveId: driveIdInt, userId: userIdInt)) + MatomoUtils.trackDeeplink(name: DeeplinkPath.store.rawValue) return true } else if components.host == DeeplinkPath.file.rawValue, @@ -57,6 +59,7 @@ public struct DeeplinkParser: DeeplinkParsable { let fileUrl = URL(fileURLWithPath: filePath) let file = ImportedFile(name: fileUrl.lastPathComponent, path: fileUrl, uti: fileUrl.uti ?? .data) await router.navigate(to: .saveFile(file: file)) + MatomoUtils.trackDeeplink(name: DeeplinkPath.file.rawValue) return true } diff --git a/kDriveCore/Utils/MatomoUtils.swift b/kDriveCore/Utils/MatomoUtils.swift index c63b72b29..1d0c46478 100644 --- a/kDriveCore/Utils/MatomoUtils.swift +++ b/kDriveCore/Utils/MatomoUtils.swift @@ -44,7 +44,7 @@ public enum MatomoUtils { public enum EventCategory: String { case newElement, fileListFileAction, picturesFileAction, fileInfo, shareAndRights, colorFolder, categories, search, fileList, comment, drive, account, settings, photoSync, home, displayList, inApp, trash, - dropbox, preview, mediaPlayer, shortcuts, appReview + dropbox, preview, mediaPlayer, shortcuts, appReview, deeplink, publicShareAction, publicSharePasswordAction } public enum UserAction: String { @@ -64,7 +64,12 @@ public enum MatomoUtils { shared.track(view: view) } - public static func track(eventWithCategory category: EventCategory, action: UserAction = .click, name: String, value: Float? = nil) { + public static func track( + eventWithCategory category: EventCategory, + action: UserAction = .click, + name: String, + value: Float? = nil + ) { shared.track(eventWithCategory: category.rawValue, action: action.rawValue, name: name, value: value) } @@ -122,4 +127,24 @@ public enum MatomoUtils { public static func trackMediaPlayer(leaveAt percentage: Double?) { track(eventWithCategory: .mediaPlayer, name: "duration", value: Float(percentage ?? 0)) } + + // MARK: - Deeplink + + public static func trackDeeplink(name: String) { + track(eventWithCategory: .deeplink, name: name) + } + + // MARK: - Public Share + + public static func trackAddToMykDrive() { + track(eventWithCategory: .publicShareAction, name: "saveToKDrive") + } + + public static func trackAddBulkToMykDrive() { + track(eventWithCategory: .publicShareAction, name: "bulkSaveToKDrive") + } + + public static func trackPublicSharePasswordAction() { + track(eventWithCategory: .publicSharePasswordAction, name: "openInBrowser") + } }