/***************************************************************************** * VLCDragAndDropManager.swift * VLC for iOS ***************************************************************************** * Copyright (c) 2017 VideoLAN. All rights reserved. * $Id$ * * Authors: Carola Nitz * * Refer to the COPYING file of the official project for license. *****************************************************************************/ import MobileCoreServices import UIKit @available(iOS 11.0, *) struct DropError: Error { enum ErrorKind { case moveFileToDocuments case loadFileRepresentationFailed } let kind: ErrorKind } @available(iOS 11.0, *) protocol VLCDragAndDropManagerDelegate: NSObjectProtocol { func dragAndDropManagerRequestsFile(manager: NSObject, atIndexPath indexPath: IndexPath) -> Any? func dragAndDropManagerInsertItem(manager: NSObject, item: NSManagedObject, atIndexPath indexPath: IndexPath) func dragAndDropManagerDeleteItem(manager: NSObject, atIndexPath indexPath: IndexPath) func dragAndDropManagerRemoveFileFromFolder(manager: NSObject, file: NSManagedObject) func dragAndDropManagerCurrentSelection(manager: NSObject) -> AnyObject? } @available(iOS 11.0, *) class VLCDragAndDropManager: NSObject, UICollectionViewDragDelegate, UITableViewDragDelegate, UICollectionViewDropDelegate, UITableViewDropDelegate, UIDropInteractionDelegate { let utiTypeIdentifiers: [String] = VLCDragAndDropManager.supportedTypeIdentifiers() var subcategory: VLCMediaSubcategoryModel /// Returns the supported type identifiers that VLC can process. /// It fetches the identifiers in LSItemContentTypes from all the CFBundleDocumentTypes in the info.plist. /// Video, Audio and Subtitle formats /// /// - Returns: Array of UTITypeIdentifiers private class func supportedTypeIdentifiers() -> [String] { var typeIdentifiers: [String] = [] if let documents = Bundle.main.infoDictionary?["CFBundleDocumentTypes"] as? [[String: Any]] { for item in documents { if let value = item["LSItemContentTypes"] as? [String] { typeIdentifiers.append(contentsOf: value) } } } return typeIdentifiers } @available(*, unavailable, message: "use init(category:)") override init() { fatalError() } init(subcategory: VLCMediaSubcategoryModel) { self.subcategory = subcategory super.init() } // MARK: - TableView func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool { return canHandleDropSession(session: session) } func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] { return dragItems(forIndexPath: indexPath) } func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { return dragItems(forIndexPath: indexPath) } func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal { let operation = dropOperation(hasActiveDrag: tableView.hasActiveDrag, firstSessionItem: session.items.first, withDestinationIndexPath: destinationIndexPath) return UITableViewDropProposal(operation: operation, intent: .insertIntoDestinationIndexPath) } func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) { let section = tableView.numberOfSections - 1 let row = tableView.numberOfRows(inSection: section) let destinationPath = coordinator.destinationIndexPath ?? IndexPath(row: row, section: section) for item in coordinator.items { let itemProvider = item.dragItem.itemProvider // we're not gonna handle moving of folders if let sourceItem = item.dragItem.localObject, fileIsCollection(file: sourceItem as AnyObject) { continue } if fileIsFolder(atIndexPath: destinationPath) { // handle dropping onto a folder addDragItem(tableView: tableView, dragItem: item, toFolderAt: destinationPath) continue } if item.sourceIndexPath != nil { // element within VLC moveItem(tableView: tableView, item: item, toIndexPath: destinationPath) continue } // Element dragging from another App let placeholder = UITableViewDropPlaceholder(insertionIndexPath: destinationPath, reuseIdentifier: VLCPlaylistTableViewCell.cellIdentifier(), rowHeight: VLCPlaylistTableViewCell.heightOfCell()) let placeholderContext = coordinator.drop(item.dragItem, to: placeholder) createFileWith(itemProvider: itemProvider) { [weak self] file, error in guard let strongSelf = self else { return } if let file = file { placeholderContext.commitInsertion() { insertionIndexPath in strongSelf.subcategory.dragAndDropManagerInsertItem(manager: strongSelf, item: file, atIndexPath: insertionIndexPath) } } if let error = error as? DropError { strongSelf.handleError(error: error, itemProvider: item.dragItem.itemProvider) placeholderContext.deletePlaceholder() } } } } private func inFolder() -> Bool { return subcategory.dragAndDropManagerCurrentSelection(manager: self) as? MLLabel != nil } private func moveItem(tableView: UITableView, item: UITableViewDropItem, toIndexPath destinationPath: IndexPath) { if let mlFile = item.dragItem.localObject as? MLFile, !mlFile.labels.isEmpty && !inFolder() { tableView.performBatchUpdates({ tableView.insertRows(at: [destinationPath], with: .automatic) subcategory.dragAndDropManagerInsertItem(manager: self, item: mlFile, atIndexPath: destinationPath) subcategory.dragAndDropManagerRemoveFileFromFolder(manager: self, file: mlFile) }, completion: nil) } } private func addDragItem(tableView: UITableView, dragItem item: UITableViewDropItem, toFolderAt index: IndexPath) { if let sourcepath = item.sourceIndexPath { // local file that just needs to be moved tableView.performBatchUpdates({ if let file = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: sourcepath) as? MLFile { tableView.deleteRows(at: [sourcepath], with: .automatic) addFile(file: file, toFolderAt: index) subcategory.dragAndDropManagerDeleteItem(manager: self, atIndexPath: sourcepath) } }, completion: nil) return } // file from other app createFileWith(itemProvider: item.dragItem.itemProvider) { [weak self] file, error in if let strongSelf = self, let file = file { strongSelf.addFile(file: file, toFolderAt: index) } } } // MARK: - Collectionview func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool { return canHandleDropSession(session: session) } func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { return dragItems(forIndexPath: indexPath) } func collectionView(_ collectionView: UICollectionView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] { return dragItems(forIndexPath: indexPath) } func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal { let operation = dropOperation(hasActiveDrag: collectionView.hasActiveDrag, firstSessionItem: session.items.first, withDestinationIndexPath: destinationIndexPath) return UICollectionViewDropProposal(operation: operation, intent: .insertIntoDestinationIndexPath) } func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) { let section = collectionView.numberOfSections - 1 let row = collectionView.numberOfItems(inSection: section) let destinationPath = coordinator.destinationIndexPath ?? IndexPath(row: row, section: section) for item in coordinator.items { if let sourceItem = item.dragItem.localObject, fileIsCollection(file: sourceItem as AnyObject) { // We're not handling moving of Collection continue } if fileIsFolder(atIndexPath: destinationPath) { // handle dropping onto a folder addDragItem(collectionView: collectionView, dragItem: item, toFolderAt: destinationPath) continue } if item.sourceIndexPath != nil { // element within VLC moveItem(collectionView: collectionView, item: item, toIndexPath: destinationPath) continue } // Element from another App let placeholder = UICollectionViewDropPlaceholder(insertionIndexPath: destinationPath, reuseIdentifier: VLCPlaylistCollectionViewCell.cellIdentifier()) let placeholderContext = coordinator.drop(item.dragItem, to: placeholder) createFileWith(itemProvider: item.dragItem.itemProvider) { [weak self] file, error in guard let strongSelf = self else { return } if let file = file { placeholderContext.commitInsertion() { insertionIndexPath in strongSelf.subcategory.dragAndDropManagerInsertItem(manager: strongSelf, item: file, atIndexPath: insertionIndexPath) } } if let error = error as? DropError { strongSelf.handleError(error: error, itemProvider: item.dragItem.itemProvider) placeholderContext.deletePlaceholder() } } } } private func moveItem(collectionView: UICollectionView, item: UICollectionViewDropItem, toIndexPath destinationPath: IndexPath) { if let mlFile = item.dragItem.localObject as? MLFile, !mlFile.labels.isEmpty && !inFolder() { collectionView.performBatchUpdates({ collectionView.insertItems(at: [destinationPath]) subcategory.dragAndDropManagerInsertItem(manager: self, item: mlFile, atIndexPath: destinationPath) subcategory.dragAndDropManagerRemoveFileFromFolder(manager: self, file: mlFile) }, completion: nil) } } private func addDragItem(collectionView: UICollectionView, dragItem item: UICollectionViewDropItem, toFolderAt index: IndexPath) { if let sourcepath = item.sourceIndexPath { // local file that just needs to be moved collectionView.performBatchUpdates({ if let file = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: sourcepath) as? MLFile { collectionView.deleteItems(at: [sourcepath]) addFile(file: file, toFolderAt: index) subcategory.dragAndDropManagerDeleteItem(manager: self, atIndexPath: sourcepath) } }, completion: nil) } else { // file from other app createFileWith(itemProvider: item.dragItem.itemProvider) { [weak self] file, error in if let strongSelf = self, let file = file { strongSelf.addFile(file: file, toFolderAt: index) } } } } // MARK: - DropInteractionDelegate for EmptyView func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool { return canHandleDropSession(session: session) } func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: UIDropSession) -> UIDropProposal { return UIDropProposal(operation: .copy) } func dropInteraction(_ interaction: UIDropInteraction, performDrop session: UIDropSession) { for item in session.items { createFileWith(itemProvider: item.itemProvider) { [weak self] _, error in if let error = error as? DropError { self?.handleError(error: error, itemProvider: item.itemProvider) } // no need to handle the file case since the libraryVC updates itself after getting a file } } } // MARK: - Shared Methods // Checks if the session has items conforming to typeidentifiers private func canHandleDropSession(session: UIDropSession) -> Bool { if session.localDragSession != nil { return true } return session.hasItemsConforming(toTypeIdentifiers: utiTypeIdentifiers) } /// Returns a drop operation type /// /// - Parameters: /// - hasActiveDrag: State if the drag started within the app /// - item: UIDragItem from session /// - Returns: UIDropOperation private func dropOperation(hasActiveDrag: Bool, firstSessionItem item: AnyObject?, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UIDropOperation { let inAlbum = subcategory.dragAndDropManagerCurrentSelection(manager: self) as? MLAlbum != nil let inShow = subcategory.dragAndDropManagerCurrentSelection(manager: self) as? MLShow != nil // you can move files into a folder or copy from anothr app into a folder if fileIsFolder(atIndexPath: destinationIndexPath) { // no dragging entire shows and albums into folders if let dragItem = item, let mlFile = dragItem.localObject as? MLFile, mlFile.isAlbumTrack() || mlFile.isShowEpisode() { return .forbidden } return hasActiveDrag ? .move : .copy } // you can't reorder if inFolder() { return hasActiveDrag ? .forbidden : .copy } // you can't reorder in or drag into an Album or Show if inAlbum || inShow { return .cancel } // we're dragging a file out of a folder if let dragItem = item, let mlFile = dragItem.localObject as? MLFile, !mlFile.labels.isEmpty { return .copy } // no reorder from another app into the top layer return hasActiveDrag ? .forbidden : .copy } /// show an Alert when dropping failed /// /// - Parameters: /// - error: the type of error that happend /// - itemProvider: the itemProvider to retrieve the suggestedName private func handleError(error: DropError, itemProvider: NSItemProvider) { let message: String let filename = itemProvider.suggestedName ?? NSLocalizedString("THIS_FILE", comment: "") switch error.kind { case .loadFileRepresentationFailed: message = String(format: NSLocalizedString("NOT_SUPPORTED_FILETYPE", comment: ""), filename) case .moveFileToDocuments: message = String(format: NSLocalizedString("FILE_EXISTS", comment: ""), filename) } let alert = UIAlertController(title: NSLocalizedString("ERROR", comment: ""), message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: nil)) UIApplication.shared.keyWindow?.rootViewController?.present(alert, animated: true, completion: nil) } private func fileIsFolder(atIndexPath indexPath: IndexPath?) -> Bool { if let indexPath = indexPath { let file = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: indexPath) return file as? MLLabel != nil } return false } private func fileIsCollection(file: Any?) -> Bool { let isFolder = file as? MLLabel != nil let isAlbum = file as? MLAlbum != nil let isShow = file as? MLShow != nil return isFolder || isAlbum || isShow } private func fileIsCollection(atIndexPath indexPath: IndexPath?) -> Bool { if let indexPath = indexPath { if let file = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: indexPath) { return fileIsCollection(file:file) } } return false } // creating dragItems for the file at indexpath private func dragItems(forIndexPath indexPath: IndexPath) -> [UIDragItem] { if let file = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: indexPath) { if fileIsCollection(atIndexPath: indexPath) { return dragItemsforCollection(file: file) } return dragItem(fromFile: file) } assert(false, "we can't generate a dragfile if the delegate can't return a file ") return [] } /// Iterates over the items of a collection to create dragitems. /// Since we're not storing collections as folders we have to provide single files /// /// - Parameter file: Can be of type MLAlbum, MLLabel or MLShow /// - Returns: An array of UIDragItems private func dragItemsforCollection(file: Any) -> [UIDragItem] { var dragItems = [UIDragItem]() var set = Set() if let folder = file as? MLLabel { set = folder.files } else if let album = file as? MLAlbum { for track in album.tracks { if let mlfile = (track as? MLAlbumTrack)?.files.first { _ = set.insert(mlfile) } } } else if let show = file as? MLShow { for episode in show.episodes { if let mlfile = (episode as? MLShowEpisode)?.files { set = set.union(mlfile) } } } else { assert(false, "can't get dragitems from a file that is not a collection") } for convertibleFile in set { if let mlfile = convertibleFile as? MLFile, let item = dragItem(fromFile: mlfile).first { dragItems.append(item) } } return dragItems } //Provides an item for other applications private func dragItem(fromFile file: Any) -> [UIDragItem] { guard let file = mlFile(from: file as AnyObject), let path = file.url else { assert(false, "can't create a dragitem if there is no file or the file has no url") return [] } let data = try? Data(contentsOf: path, options: .mappedIfSafe) let itemProvider = NSItemProvider() itemProvider.suggestedName = path.lastPathComponent // maybe use UTTypeForFileURL if let identifiers = try? path.resourceValues(forKeys: [.typeIdentifierKey]), let identifier = identifiers.typeIdentifier { // here we can show progress itemProvider.registerDataRepresentation(forTypeIdentifier: identifier, visibility: .all) { completion -> Progress? in completion(data, nil) return nil } let dragitem = UIDragItem(itemProvider: itemProvider) dragitem.localObject = file return [dragitem] } assert(false, "we can't provide a typeidentifier") return [] } private func mlFile(from file: AnyObject) -> MLFile? { if let episode = file as? MLShowEpisode, let convertedfile = episode.files.first as? MLFile { return convertedfile } if let track = file as? MLAlbumTrack, let convertedfile = track.files.first as? MLFile { return convertedfile } if let convertedfile = file as? MLFile { return convertedfile } return nil } private func addFile(file: MLFile, toFolderAt folderIndex: IndexPath) { let label = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: folderIndex) as! MLLabel DispatchQueue.main.async { _ = label.files.insert(file) file.labels = [label] file.folderTrackNumber = NSNumber(integerLiteral: label.files.count - 1) } } /// try to create a file from the dropped item /// /// - Parameters: /// - itemProvider: itemprovider which is used to load the files from /// - completion: callback with the successfully created file or error if it failed private func createFileWith(itemProvider: NSItemProvider, completion: @escaping ((MLFile?, Error?) -> Void)) { itemProvider.loadFileRepresentation(forTypeIdentifier: kUTTypeData as String) { [weak self] (url, error) in guard let strongSelf = self else { return } guard let url = url else { DispatchQueue.main.async { completion(nil, DropError(kind: .loadFileRepresentationFailed)) } return } // returns nil for local session but this should also not be called for a local session guard let destinationURL = strongSelf.moveFileToDocuments(fromURL: url) else { DispatchQueue.main.async { completion(nil, DropError(kind: .moveFileToDocuments)) } return } DispatchQueue.global(qos: .background).async { let sharedlib = MLMediaLibrary.sharedMediaLibrary() as? MLMediaLibrary sharedlib?.addFilePaths([destinationURL.path]) if let file = MLFile.file(for: destinationURL).first as? MLFile { DispatchQueue.main.async { // we dragged into a folder if let selection = strongSelf.subcategory.dragAndDropManagerCurrentSelection(manager: strongSelf) as? MLLabel { file.labels = [selection] } completion(file, nil) } } } } } private func moveFileToDocuments(fromURL filepath: URL?) -> URL? { let searchPaths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true) let newDirectoryPath = searchPaths.first guard let directoryPath = newDirectoryPath, let url = filepath else { return nil } let destinationURL = URL(fileURLWithPath: "\(directoryPath)" + "/" + "\(url.lastPathComponent)") do { try FileManager.default.moveItem(at: url, to: destinationURL) } catch let error { print(error.localizedDescription) return nil } return destinationURL } }