From c0c93262885c04661b05e70e61ce106b125a169c Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 1 Jan 2026 19:08:16 +0000 Subject: [PATCH 1/2] feat(ios): support swift 6 concurrency --- .../document-picker/ios/RNDocumentPicker.mm | 60 +++----- .../document-picker/ios/swift/DocPicker.swift | 74 ++++++---- .../document-picker/ios/swift/DocSaver.swift | 41 +++--- .../ios/swift/DocumentMetadataBuilder.swift | 2 +- .../ios/swift/FileOperations.swift | 40 +++--- .../ios/swift/IsKnownTypeImpl.swift | 26 ++-- .../ios/swift/LocalCopyResponse.swift | 8 +- .../ios/swift/PickerBase.swift | 80 +++++------ .../ios/swift/PickerOptions.swift | 76 +++++++---- .../ios/swift/PromiseSupport.swift | 2 - .../ios/swift/PromiseWrapper.swift | 129 ++++++++++-------- .../ios/swift/SaverOptions.swift | 56 +++++--- .../react-native-document-picker.podspec | 1 + .../__snapshots__/index.test.ts.snap | 1 + packages/document-picker/src/errors.ts | 12 +- packages/document-picker/src/release.ts | 2 +- packages/document-picker/src/saveDocuments.ts | 2 +- packages/document-picker/src/types.ts | 6 +- 18 files changed, 333 insertions(+), 285 deletions(-) delete mode 100644 packages/document-picker/ios/swift/PromiseSupport.swift diff --git a/packages/document-picker/ios/RNDocumentPicker.mm b/packages/document-picker/ios/RNDocumentPicker.mm index 77f7176d..b31f0097 100644 --- a/packages/document-picker/ios/RNDocumentPicker.mm +++ b/packages/document-picker/ios/RNDocumentPicker.mm @@ -18,20 +18,23 @@ @interface RNDocumentPicker () @end @implementation RNDocumentPicker { - DocPicker *docPicker; - DocSaver *docSaver; + DocPicker *_docPicker; + DocSaver *_docSaver; } -- (instancetype)init { - if ((self = [super init])) { - docPicker = [DocPicker new]; - docSaver = [DocSaver new]; +// initialization happens on serial queue so there are no races +- (DocPicker *)docPicker { + if (!_docPicker) { + _docPicker = [DocPicker new]; } - return self; + return _docPicker; } -+ (BOOL)requiresMainQueueSetup { - return NO; +- (DocSaver *)docSaver { + if (!_docSaver) { + _docSaver = [DocSaver new]; + } + return _docSaver; } RCT_EXPORT_MODULE() @@ -43,26 +46,9 @@ + (BOOL)requiresMainQueueSetup { reject: (RCTPromiseRejectBlock) reject) { - // https://stackoverflow.com/questions/5270519/what-is-difference-between-uimodaltransitionstyle-and-uimodalpresentationstyle - UIModalPresentationStyle presentationStyle = [RCTConvert UIModalPresentationStyle:options[@"presentationStyle"]]; - UIModalTransitionStyle transitionStyle = [RCTConvert UIModalTransitionStyle:options[@"transitionStyle"]]; - NSArray *allowedUTIs = [RCTConvert NSArray:options[@"type"]]; - BOOL allowMultiple = [RCTConvert BOOL:options[@"allowMultiSelection"]]; - BOOL showExtensions = [RCTConvert BOOL:options[@"showFileExtensions"]]; - NSString *mode = options[@"mode"]; - NSString *initialDir = options[@"initialDirectoryUrl"]; - BOOL requestLongTermAccess = [RCTConvert BOOL:options[@"requestLongTermAccess"]]; - - PickerOptions *pickerOptions = [[PickerOptions alloc] initWithTypes:allowedUTIs - mode:mode - initialDirectoryUrl:initialDir - allowMultiSelection:allowMultiple - shouldShowFileExtensions:showExtensions - transitionStyle:transitionStyle - presentationStyle:presentationStyle - requestLongTermAccess:requestLongTermAccess]; - - [docPicker presentWithOptions:pickerOptions resolve:resolve reject:reject]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.docPicker presentWithOptionsDict:options resolve:resolve reject:reject]; + }); } RCT_EXPORT_METHOD(pickDirectory:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { @@ -92,21 +78,13 @@ + (BOOL)requiresMainQueueSetup { } RCT_EXPORT_METHOD(writeDocuments:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - UIModalPresentationStyle presentationStyle = [RCTConvert UIModalPresentationStyle:options[@"presentationStyle"]]; - UIModalTransitionStyle transitionStyle = [RCTConvert UIModalTransitionStyle:options[@"transitionStyle"]]; - BOOL showExtensions = [RCTConvert BOOL:options[@"showFileExtensions"]]; - BOOL asCopy = [RCTConvert BOOL:options[@"copy"]]; - - NSString *initialDir = options[@"initialDirectoryUri"]; - NSArray *documentUrl = options[@"sourceUris"]; - - SaverOptions* saverOptions = [[SaverOptions alloc] initWithSourceUrlStrings:documentUrl asCopy:asCopy initialDirectoryUrl:initialDir shouldShowFileExtensions:showExtensions transitionStyle:transitionStyle presentationStyle:presentationStyle]; - - [docSaver presentWithOptions:saverOptions resolve:resolve reject:reject]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.docSaver presentWithOptionsDict:options resolve:resolve reject:reject]; + }); } RCT_EXPORT_METHOD(releaseSecureAccess:(NSArray *)uris resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - [docPicker stopAccessingOpenedUrls:uris]; + [self.docPicker stopAccessingOpenedUrls:uris]; resolve([NSNull null]); } diff --git a/packages/document-picker/ios/swift/DocPicker.swift b/packages/document-picker/ios/swift/DocPicker.swift index 1964b191..a5b5b070 100644 --- a/packages/document-picker/ios/swift/DocPicker.swift +++ b/packages/document-picker/ios/swift/DocPicker.swift @@ -2,40 +2,48 @@ import Foundation import UniformTypeIdentifiers -import MobileCoreServices -@objc public class DocPicker: PickerWithMetadataImpl { +@objc public class DocPicker: PickerBase { + var pickerOptions: PickerOptions? + var openedUrls: Set = [] - var currentOptions: PickerOptions? = nil + @MainActor + override func createDocumentPicker(from dictionary: NSDictionary) -> UIDocumentPickerViewController { + let options = PickerOptions(dictionary: dictionary) + self.pickerOptions = options + return options.createDocumentPicker() + } - @objc public func present(options: PickerOptions, resolve: @escaping RNDPPromiseResolveBlock, reject: @escaping RNDPPromiseRejectBlock) { - // TODO fix callsite param - if (!promiseWrapper.trySetPromiseRejectingIncoming(resolve, rejecter: reject, fromCallSite: "pick")) { - return; - } - currentOptions = options; - DispatchQueue.main.async { - let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: options.allowedTypes, asCopy: options.modeAsCopy()) + public override func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let promise = promiseWrapper.takeCallbacks() else { return } + let options = self.pickerOptions - documentPicker.modalPresentationStyle = options.presentationStyle - documentPicker.allowsMultipleSelection = options.allowMultiSelection - documentPicker.modalTransitionStyle = options.transitionStyle - // documentPicker.directoryURL = options.initialDirectoryUrl - // documentPicker.shouldShowFileExtensions = options.shouldShowFileExtensions + Task.detached(priority: .userInitiated) { + let documentsInfo = self.createDocumentMetadataWithOptions(for: urls, options: options) + .compactMap { $0.build() } + promise.resolve(documentsInfo) + } + } - self.presentInternal(documentPicker: documentPicker) + nonisolated private func createDocumentMetadataWithOptions(for urls: [URL], options: PickerOptions?) -> [DocumentMetadataBuilder] { + return urls.compactMap { url in + do { + return try self.getMetadataForWithOptions(url: url, options: options) + } catch { + return DocumentMetadataBuilder(forUri: url, error: error) + } } } - public func getMetadataFor(url: URL) throws -> DocumentMetadataBuilder { - return if (currentOptions?.isOpenMode() == true) { - try self.getOpenedDocumentInfo(url: url, requestLongTermAccess: currentOptions?.requestLongTermAccess ?? false) + nonisolated private func getMetadataForWithOptions(url: URL, options: PickerOptions?) throws -> DocumentMetadataBuilder { + return if options?.isOpenMode() == true { + try self.getOpenedDocumentInfo(url: url, requestLongTermAccess: options?.requestLongTermAccess ?? false) } else { try self.getAnyModeMetadata(url: url) } } - private func getAnyModeMetadata(url: URL) throws -> DocumentMetadataBuilder { + nonisolated private func getAnyModeMetadata(url: URL) throws -> DocumentMetadataBuilder { let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .nameKey, .isDirectoryKey, .contentTypeKey]) return DocumentMetadataBuilder(forUri: url, resourceValues: resourceValues) @@ -45,18 +53,20 @@ import MobileCoreServices case sourceAccessError } - func getOpenedDocumentInfo(url: URL, requestLongTermAccess: Bool) throws -> DocumentMetadataBuilder { + nonisolated private func getOpenedDocumentInfo(url: URL, requestLongTermAccess: Bool) throws -> DocumentMetadataBuilder { guard url.startAccessingSecurityScopedResource() else { throw KeepLocalCopyError.sourceAccessError } - // url.stopAccessingSecurityScopedResource() must be called later - openedUrls.append(url) + // url.stopAccessingSecurityScopedResource() must be called later by user + DispatchQueue.main.async { [weak self] in + self?.openedUrls.insert(url) + } - // Use file coordination for reading and writing any of the URL’s content. + // Use file coordination for reading and writing any of the URL's content. var error: NSError? = nil var success = false - var metadataBuilder: DocumentMetadataBuilder = DocumentMetadataBuilder(forUri: url) + var metadataBuilder = DocumentMetadataBuilder(forUri: url) NSFileCoordinator().coordinate(readingItemAt: url, error: &error) { (url) in do { @@ -66,7 +76,7 @@ import MobileCoreServices metadataBuilder.setMetadataReadingError(error) } - if (requestLongTermAccess == true) { + if requestLongTermAccess { do { let bookmarkData = try url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil) metadataBuilder.setBookmark(bookmarkData) @@ -75,10 +85,18 @@ import MobileCoreServices } } } - if let err = error, success == false { + if let err = error, !success { throw err } return metadataBuilder } + @objc public func stopAccessingOpenedUrls(_ urlStrings: [String]) { + let incomingUrls = Set(urlStrings.compactMap { URL(string: $0) }) + for url in openedUrls.intersection(incomingUrls) { + url.stopAccessingSecurityScopedResource() + openedUrls.remove(url) + } + } + } diff --git a/packages/document-picker/ios/swift/DocSaver.swift b/packages/document-picker/ios/swift/DocSaver.swift index d214fe3d..9d0f0b6b 100644 --- a/packages/document-picker/ios/swift/DocSaver.swift +++ b/packages/document-picker/ios/swift/DocSaver.swift @@ -9,33 +9,28 @@ import Foundation import UniformTypeIdentifiers -import MobileCoreServices -@objc public class DocSaver: PickerWithMetadataImpl { +@objc public class DocSaver: PickerBase { - @objc public func present(options: SaverOptions, resolve: @escaping (Any?) -> Void, reject: @escaping (String?, String?, Error?) -> Void) { - if (!promiseWrapper.trySetPromiseRejectingIncoming(resolve, rejecter: reject, fromCallSite: "saveDocuments")) { - return; - } - DispatchQueue.main.async { - let documentPicker = UIDocumentPickerViewController(forExporting: options.sourceUrls, asCopy: options.asCopy) - - documentPicker.modalPresentationStyle = options.presentationStyle - documentPicker.modalTransitionStyle = options.transitionStyle - // documentPicker.directoryURL = options.initialDirectoryUrl - // documentPicker.shouldShowFileExtensions = options.shouldShowFileExtensions - - self.presentInternal(documentPicker: documentPicker) - } + @MainActor + override func createDocumentPicker(from dictionary: NSDictionary) -> UIDocumentPickerViewController { + let options = SaverOptions(dictionary: dictionary) + return options.createDocumentPicker() } - public func getMetadataFor(url: URL) throws -> DocumentMetadataBuilder { - let name = url.lastPathComponent.removingPercentEncoding - - var resourceValues = URLResourceValues() - resourceValues.name = name - - return DocumentMetadataBuilder(forUri: url, resourceValues: resourceValues) + public override func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let promise = promiseWrapper.takeCallbacks() else { return } + + Task.detached(priority: .userInitiated) { + // runs off main thread - preserves I/O performance + let documentsInfo = urls.compactMap { url -> [String: Any?]? in + let name = url.lastPathComponent.removingPercentEncoding + var resourceValues = URLResourceValues() + resourceValues.name = name + return DocumentMetadataBuilder(forUri: url, resourceValues: resourceValues).build() + } + promise.resolve(documentsInfo) + } } } diff --git a/packages/document-picker/ios/swift/DocumentMetadataBuilder.swift b/packages/document-picker/ios/swift/DocumentMetadataBuilder.swift index 35054f0f..a31b960c 100644 --- a/packages/document-picker/ios/swift/DocumentMetadataBuilder.swift +++ b/packages/document-picker/ios/swift/DocumentMetadataBuilder.swift @@ -39,7 +39,7 @@ public class DocumentMetadataBuilder { func build() -> [String: Any?] { var dictionary: [String: Any?] = [:] - if (resourceValues?.isDirectory ?? false == false) { + if resourceValues?.isDirectory != true { let utTypeFromFile: UTType? = resourceValues?.contentType let utType: UTType? = utTypeFromFile ?? UTType(filenameExtension: uri.pathExtension) diff --git a/packages/document-picker/ios/swift/FileOperations.swift b/packages/document-picker/ios/swift/FileOperations.swift index 6f1682ff..19b96fa8 100644 --- a/packages/document-picker/ios/swift/FileOperations.swift +++ b/packages/document-picker/ios/swift/FileOperations.swift @@ -1,21 +1,23 @@ // LICENSE: see License.md in the package root import Foundation +import React @objc public class FileOperations: NSObject { - - @objc public static func keepLocalCopyAtUniqueDestination(from: [[String: String]], destinationPreset: String, resolve: @escaping RNDPPromiseResolveBlock) { - DispatchQueue.global(qos: .utility).async { - let results = moveFiles(from: from, destinationPreset: destinationPreset) - resolve(results) + + @objc public static func keepLocalCopyAtUniqueDestination(from: [[String: String]], destinationPreset: String, resolve: @escaping RCTPromiseResolveBlock) { + let callback = ResolveCallback(resolve: resolve) + Task.detached(priority: .userInitiated) { + let results = Self.moveFiles(from: from, destinationPreset: destinationPreset) + callback.resolve(results) } } - - static func moveFiles(from: [[String: String]], destinationPreset: String) -> [[String: String?]] { + + private static func moveFiles(from: [[String: String]], destinationPreset: String) -> [[String: String?]] { let destinationRootDir = getDirectoryForFileDestination(destinationPreset) let uniqueSubDirName = UUID().uuidString let destinationDir = destinationRootDir.appendingPathComponent(uniqueSubDirName, isDirectory: true) - + do { try FileManager.default.createDirectory(at: destinationDir, withIntermediateDirectories: true, attributes: nil) } catch { @@ -23,13 +25,13 @@ import Foundation LocalCopyResponse.error(sourceUri: dictionary["uri"], copyError: "Failed to create destination directory: \(error.localizedDescription)").dictionaryRepresentation } } - + // move files return from.map { dictionary in - moveSingleFile(dictionary: dictionary, destinationDir: destinationDir).dictionaryRepresentation + Self.moveSingleFile(dictionary: dictionary, destinationDir: destinationDir).dictionaryRepresentation } } - + private static func moveSingleFile(dictionary: [String: String], destinationDir: URL) -> LocalCopyResponse { guard let uriString = dictionary["uri"], let uri = URL(string: uriString), @@ -39,7 +41,7 @@ import Foundation copyError: "Invalid URI or fileName" ) } - + do { let destinationUrl = try moveToDestination(from: uri, usingFilename: fileName, destinationDir: destinationDir) return LocalCopyResponse.success(sourceUri: uri.absoluteString, localUri: destinationUrl.absoluteString) @@ -47,10 +49,10 @@ import Foundation return LocalCopyResponse.error(sourceUri: uriString, copyError: error.localizedDescription) } } - - static func moveToDestination(from: URL, usingFilename fileName: String, destinationDir: URL) throws -> URL { + + private static func moveToDestination(from: URL, usingFilename fileName: String, destinationDir: URL) throws -> URL { let destinationFile = destinationDir.appendingPathComponent(fileName).standardized - + guard destinationFile.path.hasPrefix(destinationDir.standardized.path) else { throw NSError( domain: "PathTraversalPrevention", @@ -58,13 +60,13 @@ import Foundation userInfo: [NSLocalizedDescriptionKey: "The copied file is attempting to write outside of the target directory."] ) } - + try FileManager.default.moveItem(at: from, to: destinationFile) - + return destinationFile } - - static func getDirectoryForFileDestination(_ copyToDirectory: String) -> URL { + + private static func getDirectoryForFileDestination(_ copyToDirectory: String) -> URL { let searchPath: FileManager.SearchPathDirectory = copyToDirectory == "documentDirectory" ? .documentDirectory : .cachesDirectory return FileManager.default.urls(for: searchPath, in: .userDomainMask).first! } diff --git a/packages/document-picker/ios/swift/IsKnownTypeImpl.swift b/packages/document-picker/ios/swift/IsKnownTypeImpl.swift index 871525c4..b96b478f 100644 --- a/packages/document-picker/ios/swift/IsKnownTypeImpl.swift +++ b/packages/document-picker/ios/swift/IsKnownTypeImpl.swift @@ -10,20 +10,26 @@ import Foundation import UniformTypeIdentifiers @objc public class IsKnownTypeImpl: NSObject { - - @objc public static func checkType(_ kind: String, value: String) -> NSDictionary { - let dict = getTypeResult(kind, value: value) - return NSDictionary(dictionary: dict as [AnyHashable: Any]) + + @objc public static func checkType(_ kind: String, value: String) -> [String: Any] { + return getTypeResult(kind, value: value) } - static func getTypeResult(_ kind: String, value: String) -> Dictionary { + static func getTypeResult(_ kind: String, value: String) -> [String: Any] { if let utType = createUTType(kind: kind, value: value), utType.isDeclared == true { - return ["isKnown": true, - "UTType": utType.identifier, - "preferredFilenameExtension": utType.preferredFilenameExtension, - "mimeType": utType.preferredMIMEType] + return [ + "isKnown": true, + "UTType": utType.identifier, + "preferredFilenameExtension": utType.preferredFilenameExtension ?? NSNull(), + "mimeType": utType.preferredMIMEType ?? NSNull() + ] } - return ["isKnown": false, "UTType": nil, "preferredFilenameExtension": nil, "mimeType": nil] + return [ + "isKnown": false, + "UTType": NSNull(), + "preferredFilenameExtension": NSNull(), + "mimeType": NSNull() + ] } static func createUTType(kind: String, value: String) -> UTType? { diff --git a/packages/document-picker/ios/swift/LocalCopyResponse.swift b/packages/document-picker/ios/swift/LocalCopyResponse.swift index 68af516d..b82737c7 100644 --- a/packages/document-picker/ios/swift/LocalCopyResponse.swift +++ b/packages/document-picker/ios/swift/LocalCopyResponse.swift @@ -15,13 +15,11 @@ enum LocalCopyResponse { case error(sourceUri: String?, copyError: String) var dictionaryRepresentation: [String: String?] { - switch self { + return switch self { case .success(let sourceUri, let localUri): - return ["sourceUri": sourceUri, "localUri": localUri, "status": "success"] + ["sourceUri": sourceUri, "localUri": localUri, "status": "success"] case .error(let sourceUri, let copyError): - var result = ["copyError": copyError, "status": "error"] - result["sourceUri"] = sourceUri ?? nil - return result + ["sourceUri": sourceUri, "copyError": copyError, "status": "error"] } } } diff --git a/packages/document-picker/ios/swift/PickerBase.swift b/packages/document-picker/ios/swift/PickerBase.swift index f4b984c4..05304e17 100644 --- a/packages/document-picker/ios/swift/PickerBase.swift +++ b/packages/document-picker/ios/swift/PickerBase.swift @@ -1,5 +1,5 @@ // -// DocSaver.swift +// PickerBase.swift // react-native-document-picker // // Created by Vojtech Novak on 25.05.2024. @@ -9,55 +9,46 @@ import Foundation import UniformTypeIdentifiers -import MobileCoreServices - -public protocol GetsMetadataProtocol { - func getMetadataFor(url: URL) throws -> DocumentMetadataBuilder -} - -// https://stackoverflow.com/a/51333906/2070942 -public typealias PickerWithMetadataImpl = PickerBase & GetsMetadataProtocol +import React public class PickerBase: NSObject, UIDocumentPickerDelegate, UIAdaptivePresentationControllerDelegate { let promiseWrapper = PromiseWrapper() - var openedUrls: Array = [] - func presentInternal(documentPicker: UIDocumentPickerViewController) { - documentPicker.delegate = self - documentPicker.presentationController?.delegate = self; - - if let viewController = RCTPresentedViewController() { - viewController.present(documentPicker, animated: true, completion: nil) - } else { - let error = NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: nil) - promiseWrapper.reject("RCTPresentedViewController was nil", withCode: "PRESENTER_IS_NULL", withError: error) + @MainActor + @objc public func present(optionsDict: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard promiseWrapper.trySetPromiseRejectingIncoming(resolve, rejecter: reject) else { + return } + + presentInternal(optionsDict) + } + + @MainActor + func createDocumentPicker(from dictionary: NSDictionary) -> UIDocumentPickerViewController { + fatalError("Subclasses must override createDocumentPicker(from:)") } - + + // Subclasses must override this method to process picked documents public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - DispatchQueue.global(qos: .userInitiated).async { - // this doesn't run on the main thread - let documentsInfo = urls.compactMap(self.createDocumentMetadata).compactMap { $0.build() } - self.promiseWrapper.resolve(documentsInfo) - } - // https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/DocumentPickerProgrammingGuide/AccessingDocuments/AccessingDocuments.html#//apple_ref/doc/uid/TP40014451-CH2-SW4 "Accessing Files Outside Your Sandbox" - // https://developer.apple.com/documentation/uikit/view_controllers/providing_access_to_directories + fatalError("Subclasses must override documentPicker(_:didPickDocumentsAt:)") } - - private func createDocumentMetadata(for url: URL) -> DocumentMetadataBuilder? { - guard let subclassThatGetsMetadata = self as? GetsMetadataProtocol else { - let error = NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: nil) - self.promiseWrapper.reject("PickerBase", withCode: "BAD_CLASS", withError: error) - return nil - } - - do { - return try subclassThatGetsMetadata.getMetadataFor(url: url) - } catch { - return DocumentMetadataBuilder(forUri: url, error: error) + + @MainActor + func presentInternal(_ optionsDict: NSDictionary) { + let documentPicker = self.createDocumentPicker(from: optionsDict) + + documentPicker.delegate = self + documentPicker.presentationController?.delegate = self + + if let viewController = RCTPresentedViewController() { + viewController.present(documentPicker, animated: true, completion: nil) + } else { + promiseWrapper.takeCallbacks()?.reject("RCTPresentedViewController was nil", withCode: "NULL_PRESENTER", withError: nil) } } - + public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { promiseWrapper.rejectAsUserCancelledOperation() } @@ -66,13 +57,4 @@ public class PickerBase: NSObject, UIDocumentPickerDelegate, UIAdaptivePresentat promiseWrapper.rejectAsUserCancelledOperation() } - @objc public func stopAccessingOpenedUrls(_ urlStrings: [String]) { - let incomingUrls = Set(urlStrings.compactMap { URL(string: $0) }) - openedUrls.removeAll { url in - guard incomingUrls.contains(url) else { return false } - url.stopAccessingSecurityScopedResource() - return true - } - } - } diff --git a/packages/document-picker/ios/swift/PickerOptions.swift b/packages/document-picker/ios/swift/PickerOptions.swift index 3df42bac..98ee79ae 100644 --- a/packages/document-picker/ios/swift/PickerOptions.swift +++ b/packages/document-picker/ios/swift/PickerOptions.swift @@ -3,42 +3,72 @@ import Foundation import UIKit import UniformTypeIdentifiers +import React -@objc public class PickerOptions: NSObject { +public enum PickerMode: String, Sendable { + case `import` = "import" + case open = "open" +} + +public struct PickerOptions: Sendable { let allowedTypes: Array - let mode: String // "import" or "open" + let mode: PickerMode let allowMultiSelection: Bool - let transitionStyle: UIModalTransitionStyle - let presentationStyle: UIModalPresentationStyle - let initialDirectoryUrl: URL? + var transitionStyle: UIModalTransitionStyle? + var presentationStyle: UIModalPresentationStyle? + var initialDirectoryUri: URL? let shouldShowFileExtensions: Bool let requestLongTermAccess: Bool - - @objc public init(types: Array, mode: String = "import", initialDirectoryUrl: String? = nil, allowMultiSelection: Bool, shouldShowFileExtensions: Bool, transitionStyle: UIModalTransitionStyle = .coverVertical, presentationStyle: UIModalPresentationStyle = .fullScreen, requestLongTermAccess: Bool = false) { + + public init(dictionary: NSDictionary) { + let types = dictionary["type"] as? [String] ?? [] + let modeString = dictionary["mode"] as? String ?? "import" + let allowMultiSelection = dictionary["allowMultiSelection"] as? Bool ?? false + let shouldShowFileExtensions = dictionary["showFileExtensions"] as? Bool ?? false + let requestLongTermAccess = dictionary["requestLongTermAccess"] as? Bool ?? false + // TODO check if types were valid - allowedTypes = types.compactMap { - UTType($0) - } + self.allowedTypes = types.compactMap { UTType($0) } + self.mode = PickerMode(rawValue: modeString) ?? .import self.allowMultiSelection = allowMultiSelection - self.transitionStyle = transitionStyle - self.presentationStyle = presentationStyle - self.mode = mode - if let unwrappedUrl = initialDirectoryUrl, let url = URL(string: unwrappedUrl) { - self.initialDirectoryUrl = url - } else { - self.initialDirectoryUrl = nil - } self.shouldShowFileExtensions = shouldShowFileExtensions self.requestLongTermAccess = requestLongTermAccess + + if let transitionStyle = dictionary["transitionStyle"] as? String { + self.transitionStyle = RCTConvert.uiModalTransitionStyle(transitionStyle) + } + if let presentationStyle = dictionary["presentationStyle"] as? String { + self.presentationStyle = RCTConvert.uiModalPresentationStyle(presentationStyle) + } + if let initialDirectoryUri = dictionary["initialDirectoryUri"] as? String, let url = URL(string: initialDirectoryUri) { + self.initialDirectoryUri = url + } } - + // asCopy: if true, the picker will give you access to a local copy of the document, otherwise you will have access to the original document - public func modeAsCopy() -> Bool { - return self.mode == "import" + func modeAsCopy() -> Bool { + return self.mode == .import + } + + func isOpenMode() -> Bool { + return self.mode == .open } - public func isOpenMode() -> Bool { - return self.mode == "open" + @MainActor + public func createDocumentPicker() -> UIDocumentPickerViewController { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: allowedTypes, asCopy: modeAsCopy()) + picker.allowsMultipleSelection = allowMultiSelection + + if let presentationStyle = presentationStyle { + picker.modalPresentationStyle = presentationStyle + } + if let transitionStyle = transitionStyle { + picker.modalTransitionStyle = transitionStyle + } + picker.directoryURL = initialDirectoryUri + picker.shouldShowFileExtensions = shouldShowFileExtensions + + return picker } } diff --git a/packages/document-picker/ios/swift/PromiseSupport.swift b/packages/document-picker/ios/swift/PromiseSupport.swift deleted file mode 100644 index e43eda1b..00000000 --- a/packages/document-picker/ios/swift/PromiseSupport.swift +++ /dev/null @@ -1,2 +0,0 @@ -public typealias RNDPPromiseResolveBlock = ((Any?) -> Void) -public typealias RNDPPromiseRejectBlock = (String?, String?, Error?) -> Void diff --git a/packages/document-picker/ios/swift/PromiseWrapper.swift b/packages/document-picker/ios/swift/PromiseWrapper.swift index e780c69a..0e659366 100644 --- a/packages/document-picker/ios/swift/PromiseWrapper.swift +++ b/packages/document-picker/ios/swift/PromiseWrapper.swift @@ -1,61 +1,45 @@ // LICENSE: see License.md in the package root import Foundation +import React -class PromiseWrapper { - private var promiseResolve: RNDPPromiseResolveBlock? - private var promiseReject: RNDPPromiseRejectBlock? - private var nameOfCallInProgress: String? +/// React Native promise blocks are safe to call from background queues, but they are not annotated +/// as `Sendable`. Wrap them so Swift 6 doesn't warn when captured by `@Sendable` closures +/// (e.g. `Task.detached`, GCD `DispatchQueue.async`). +struct ResolveCallback: @unchecked Sendable { + let resolve: RCTPromiseResolveBlock +} - private let E_DOCUMENT_PICKER_CANCELED = "OPERATION_CANCELED" - private let ASYNC_OP_IN_PROGRESS = "ASYNC_OP_IN_PROGRESS" +/// React Native promise blocks are safe to call from background queues, but they are not annotated +/// as `Sendable`. Wrap them so Swift 6 doesn't warn when captured by `@Sendable` closures +/// (e.g. `Task.detached`, GCD `DispatchQueue.async`). +struct RejectCallback: @unchecked Sendable { + let reject: RCTPromiseRejectBlock +} - func setPromiseRejectingPrevious(_ resolve: @escaping RNDPPromiseResolveBlock, - rejecter reject: @escaping RNDPPromiseRejectBlock, - fromCallSite callsite: String) { - if let previousReject = promiseReject { - rejectPreviousPromiseBecauseNewOneIsInProgress(previousReject, requestedOperation: callsite) - } - promiseResolve = resolve - promiseReject = reject - nameOfCallInProgress = callsite - } +/// Promise callbacks for React Native bridges. +/// +/// React Native promise blocks are safe to call from background queues, but they are not annotated +/// as `Sendable`. We wrap them so this value can be captured by `@Sendable` closures without +/// Swift 6 warnings. +struct PromiseCallbacks: @unchecked Sendable { + private let resolveCallback: ResolveCallback + private let rejectCallback: RejectCallback - func trySetPromiseRejectingIncoming(_ resolve: @escaping RNDPPromiseResolveBlock, - rejecter reject: @escaping RNDPPromiseRejectBlock, - fromCallSite callsite: String) -> Bool { - if promiseReject != nil { - rejectNewPromiseBecauseOldOneIsInProgress(reject, requestedOperation: callsite) - return false - } - promiseResolve = resolve - promiseReject = reject - nameOfCallInProgress = callsite - return true - } + private let E_DOCUMENT_PICKER_CANCELED = "OPERATION_CANCELED" - func resolve(_ result: Any?) { - guard let resolver = promiseResolve else { - print("cannot resolve promise because it's null") - return - } - resetMembers() - resolver(result) + init(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + self.resolveCallback = ResolveCallback(resolve: resolve) + self.rejectCallback = RejectCallback(reject: reject) } - func reject(_ message: String, withError error: NSError) { - let errorCode = String(error.code) - reject(message, withCode: errorCode, withError: error) + func resolve(_ result: Any?) { + resolveCallback.resolve(result) } - func reject(_ message: String, withCode errorCode: String, withError error: NSError) { - guard let rejecter = promiseReject else { - print("cannot reject promise because it's null") - return - } - let errorMessage = "RNDPPromiseWrapper: \(message), \(error.description)" - resetMembers() - rejecter(errorCode, errorMessage, error) + func reject(_ message: String, withCode code: String, withError error: NSError?) { + let errorMessage = "RNDocumentPicker: \(message)\(error.map { ", \($0.description)" } ?? "")" + rejectCallback.reject(code, errorMessage, error) } func rejectAsUserCancelledOperation() { @@ -66,27 +50,56 @@ class PromiseWrapper { withCode: E_DOCUMENT_PICKER_CANCELED, withError: error) } +} - private func resetMembers() { - promiseResolve = nil - promiseReject = nil +/// Promise lifecycle manager. +/// +/// **Lifecycle:** Stores the promise callbacks between the initial call and delegate callbacks. +/// Call `takeCallbacks()` to extract callbacks for async operations. +/// +/// **Usage:** Cancellation/dismissal settles the in-flight promise (on whatever queue calls it). +final class PromiseWrapper { + private var callbacks: PromiseCallbacks? + private var nameOfCallInProgress: String? + + private let ASYNC_OP_IN_PROGRESS = "ASYNC_OP_IN_PROGRESS" + + func trySetPromiseRejectingIncoming(_ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock, + fromCallSite callsite: String = #function) -> Bool { + if callbacks != nil { + let newCallbacks = PromiseCallbacks(resolve: resolve, reject: reject) + rejectNewPromiseBecauseOldOneIsInProgress(newCallbacks, requestedOperation: callsite) + return false + } + callbacks = PromiseCallbacks(resolve: resolve, reject: reject) + nameOfCallInProgress = callsite + return true + } + + /// Extract callbacks for async work (e.g. file I/O off the main thread). + /// After extraction, this wrapper is cleared and the callbacks are moved out. + func takeCallbacks(caller: String = #function) -> PromiseCallbacks? { + guard let cb = callbacks else { + NSLog("RNDocumentPicker: \(caller) called with no in-flight promise. Dropping result.") + return nil + } + + callbacks = nil nameOfCallInProgress = nil + return cb } - // TODO error messages - private func rejectPreviousPromiseBecauseNewOneIsInProgress(_ reject: RNDPPromiseRejectBlock, - requestedOperation callSiteName: String) { - let msg = "Warning: previous promise did not settle and was overwritten. " + - "You've called \"\(callSiteName)\" while \"\(nameOfCallInProgress ?? "")\" " + - "was already in progress and has not completed yet." - reject(ASYNC_OP_IN_PROGRESS, msg, nil) + func rejectAsUserCancelledOperation() { + takeCallbacks()?.rejectAsUserCancelledOperation() } - private func rejectNewPromiseBecauseOldOneIsInProgress(_ reject: RNDPPromiseRejectBlock, + private func rejectNewPromiseBecauseOldOneIsInProgress(_ callbacks: PromiseCallbacks, requestedOperation callSiteName: String) { let msg = "Warning: previous promise did not settle and you attempted to overwrite it. " + "You've called \"\(callSiteName)\" while \"\(nameOfCallInProgress ?? "")\" " + "was already in progress and has not completed yet." - reject(ASYNC_OP_IN_PROGRESS, msg, nil) + let error = NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: [NSLocalizedDescriptionKey: msg]) + callbacks.reject(msg, withCode: ASYNC_OP_IN_PROGRESS, withError: error) } } diff --git a/packages/document-picker/ios/swift/SaverOptions.swift b/packages/document-picker/ios/swift/SaverOptions.swift index 35ab693f..b6c4b458 100644 --- a/packages/document-picker/ios/swift/SaverOptions.swift +++ b/packages/document-picker/ios/swift/SaverOptions.swift @@ -3,28 +3,50 @@ import Foundation import UIKit import UniformTypeIdentifiers +import React -@objc public class SaverOptions: NSObject { - let transitionStyle: UIModalTransitionStyle - let presentationStyle: UIModalPresentationStyle - let initialDirectoryUrl: URL? +public struct SaverOptions: Sendable { + var transitionStyle: UIModalTransitionStyle? + var presentationStyle: UIModalPresentationStyle? + var initialDirectoryUri: URL? let sourceUrls: [URL] let shouldShowFileExtensions: Bool let asCopy: Bool - - @objc public init(sourceUrlStrings: [String], asCopy: Bool, initialDirectoryUrl: String? = nil, shouldShowFileExtensions: Bool, transitionStyle: UIModalTransitionStyle = .coverVertical, presentationStyle: UIModalPresentationStyle = .fullScreen) { - self.sourceUrls = sourceUrlStrings.map({ it in - URL(string: it)! - }) + + public init(dictionary: NSDictionary) { + let sourceUrlStrings = dictionary["sourceUris"] as? [String] ?? [] + let asCopy = dictionary["copy"] as? Bool ?? false + let shouldShowFileExtensions = dictionary["showFileExtensions"] as? Bool ?? false + + self.sourceUrls = sourceUrlStrings.compactMap { URL(string: $0) } self.asCopy = asCopy - self.transitionStyle = transitionStyle - self.presentationStyle = presentationStyle - if let unwrappedUrl = initialDirectoryUrl, let url = URL(string: unwrappedUrl) { - self.initialDirectoryUrl = url - } else { - self.initialDirectoryUrl = nil - } self.shouldShowFileExtensions = shouldShowFileExtensions + + if let transitionStyle = dictionary["transitionStyle"] as? String { + self.transitionStyle = RCTConvert.uiModalTransitionStyle(transitionStyle) + } + if let presentationStyle = dictionary["presentationStyle"] as? String { + self.presentationStyle = RCTConvert.uiModalPresentationStyle(presentationStyle) + } + if let initialDirectoryUri = dictionary["initialDirectoryUri"] as? String, let url = URL(string: initialDirectoryUri) { + self.initialDirectoryUri = url + } } - + + @MainActor + public func createDocumentPicker() -> UIDocumentPickerViewController { + let picker = UIDocumentPickerViewController(forExporting: sourceUrls, asCopy: asCopy) + + if let presentationStyle = presentationStyle { + picker.modalPresentationStyle = presentationStyle + } + if let transitionStyle = transitionStyle { + picker.modalTransitionStyle = transitionStyle + } + picker.directoryURL = initialDirectoryUri + picker.shouldShowFileExtensions = shouldShowFileExtensions + + return picker + } + } diff --git a/packages/document-picker/react-native-document-picker.podspec b/packages/document-picker/react-native-document-picker.podspec index b5f41560..96ec4802 100644 --- a/packages/document-picker/react-native-document-picker.podspec +++ b/packages/document-picker/react-native-document-picker.podspec @@ -14,6 +14,7 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/react-native-documents/sponsors-only.git", :tag => "v#{s.version}" } s.source_files = ["ios/**/*.{h,m,mm,swift}"] + s.swift_version = '6.0' # Swift/Objective-C compatibility s.pod_target_xcconfig = { diff --git a/packages/document-picker/src/__tests__/__snapshots__/index.test.ts.snap b/packages/document-picker/src/__tests__/__snapshots__/index.test.ts.snap index b2d90a6e..99fda06e 100644 --- a/packages/document-picker/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/document-picker/src/__tests__/__snapshots__/index.test.ts.snap @@ -4,6 +4,7 @@ exports[`DocumentPicker mock should have the expected methods 1`] = ` { "errorCodes": { "IN_PROGRESS": "ASYNC_OP_IN_PROGRESS", + "NULL_PRESENTER": "NULL_PRESENTER", "OPERATION_CANCELED": "OPERATION_CANCELED", "UNABLE_TO_OPEN_FILE_TYPE": "UNABLE_TO_OPEN_FILE_TYPE", }, diff --git a/packages/document-picker/src/errors.ts b/packages/document-picker/src/errors.ts index 6dba360c..caa7a894 100644 --- a/packages/document-picker/src/errors.ts +++ b/packages/document-picker/src/errors.ts @@ -1,10 +1,7 @@ -export interface NativeModuleError extends Error { - code: string -} - const OPERATION_CANCELED = 'OPERATION_CANCELED' const IN_PROGRESS = 'ASYNC_OP_IN_PROGRESS' const UNABLE_TO_OPEN_FILE_TYPE = 'UNABLE_TO_OPEN_FILE_TYPE' +const NULL_PRESENTER = 'NULL_PRESENTER' /** * Error codes that can be returned by the module, and are available on the `code` property of the error. @@ -36,8 +33,15 @@ export const errorCodes = Object.freeze({ OPERATION_CANCELED, IN_PROGRESS, UNABLE_TO_OPEN_FILE_TYPE, + NULL_PRESENTER, }) +type ErrorCodes = (typeof errorCodes)[keyof typeof errorCodes] + +export interface NativeModuleError extends Error { + code: ErrorCodes | (string & {}) +} + /** * TypeScript helper to check if an object has the `code` property. * This is used to avoid `as` casting when you access the `code` property on errors returned by the module. diff --git a/packages/document-picker/src/release.ts b/packages/document-picker/src/release.ts index 69e81d72..cc527e7b 100644 --- a/packages/document-picker/src/release.ts +++ b/packages/document-picker/src/release.ts @@ -1,7 +1,7 @@ import { NativeDocumentPicker } from './spec/NativeDocumentPicker' /** - * For each uri whose release was requested, the result will contain an object with the uri and a status. + * For each uri whose release was requested, the result contains an object with the uri and a status. * */ export type ReleaseLongTermAccessResult = Array< | { diff --git a/packages/document-picker/src/saveDocuments.ts b/packages/document-picker/src/saveDocuments.ts index f2776f1d..71844fb5 100644 --- a/packages/document-picker/src/saveDocuments.ts +++ b/packages/document-picker/src/saveDocuments.ts @@ -15,7 +15,7 @@ export type SaveDocumentsOptions = { sourceUris: string[] /** * Android-only: The MIME type of the file to be stored. - * It is recommended to provide this value, otherwise the system will try to infer it from the sourceUri using ContentResolver. + * It is recommended to provide this value, otherwise the system tries to infer it from the sourceUri using ContentResolver. * */ mimeType?: string /** diff --git a/packages/document-picker/src/types.ts b/packages/document-picker/src/types.ts index 98ecbcbc..de222665 100644 --- a/packages/document-picker/src/types.ts +++ b/packages/document-picker/src/types.ts @@ -25,7 +25,7 @@ export type VirtualFileMeta = { /** * The registered extension for the given MIME type. Note that some MIME types map to multiple extensions. * - * This call will return the most common extension for the given MIME type. + * This call returns the most common extension for the given MIME type. * * Example: `pdf` */ @@ -68,7 +68,7 @@ export type DocumentPickerResponse = { size: number | null /** - * Android: whether the file is a virtual file (such as Google docs or sheets). Will be `null` on pre-Android 7.0 devices. On iOS, it's always `false`. + * Android: whether the file is a virtual file (such as Google docs or sheets). This is `null` on pre-Android 7.0 devices. On iOS, it's always `false`. * */ isVirtual: boolean | null /** @@ -80,7 +80,7 @@ export type DocumentPickerResponse = { /** * Android: Some document providers on Android (especially those popular in Asia, it seems) * do not respect the request for limiting selectable file types. - * `hasRequestedType` will be false if the user picked a file that does not have one of the requested types. + * `hasRequestedType` is false if the user picked a file that does not have one of the requested types. * * You need to do your own post-processing and display an error to the user if this is important to your app. * From b268326efeaabe40609957a046be30fd80dcb10c Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Fri, 2 Jan 2026 23:35:16 +0100 Subject: [PATCH 2/2] Update picker package version to major --- .changeset/spotty-queens-smash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/spotty-queens-smash.md diff --git a/.changeset/spotty-queens-smash.md b/.changeset/spotty-queens-smash.md new file mode 100644 index 00000000..aad59605 --- /dev/null +++ b/.changeset/spotty-queens-smash.md @@ -0,0 +1,5 @@ +--- +"@react-native-documents/picker": major +--- + +feat(ios): support swift 6 concurrency