first commit

This commit is contained in:
2026-03-10 16:18:05 +00:00
commit 11f9c069b5
31635 changed files with 3187747 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'ExpoFileSystem'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = package['author']
s.homepage = package['homepage']
s.platforms = {
:ios => '15.1',
:osx => '11.0',
:tvos => '15.1'
}
s.swift_version = '5.9'
s.source = { :git => 'https://github.com/expo/expo.git' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES'
}
s.resource_bundles = {'ExpoFileSystem_privacy' => ['PrivacyInfo.xcprivacy']}
s.source_files = "**/*.{h,m,swift}"
end

View File

@@ -0,0 +1,95 @@
// UIKit is unavailable on macOS, so platform checks are necessary.
// For macOS support, we should consider using NSOpenPanel: https://developer.apple.com/documentation/appkit/nsopenpanel
// UIDocumentPickerViewController is unavailable on tvOS
#if os(iOS)
import ExpoModulesCore
import UIKit
internal class FilePickingHandler: FilePickingResultHandler {
private weak var module: FileSystemModule?
internal var filePickingContext: FilePickingContext?
init(module: FileSystemModule) {
self.module = module
}
func presentDocumentPicker(
picker: UIDocumentPickerViewController,
isDirectory: Bool,
initialUri: URL?,
mimeType: String?,
promise: Promise
) {
guard let module = module else {
promise.reject(MissingViewControllerException())
return
}
if filePickingContext != nil {
promise.reject(PickingInProgressException())
return
}
guard let currentVc = module.appContext?.utilities?.currentViewController() else {
promise.reject(MissingViewControllerException())
return
}
let pickerDelegate = FilePickingDelegate(resultHandler: self, isDirectory: isDirectory)
filePickingContext = FilePickingContext(
promise: promise,
initialUri: initialUri,
mimeType: mimeType,
isDirectory: isDirectory,
delegate: pickerDelegate
)
picker.delegate = pickerDelegate
picker.presentationController?.delegate = pickerDelegate
picker.allowsMultipleSelection = false
if UIDevice.current.userInterfaceIdiom == .pad {
let viewFrame = currentVc.view.frame
picker.popoverPresentationController?.sourceRect = CGRect(
x: viewFrame.midX,
y: viewFrame.maxY,
width: 0,
height: 0
)
picker.popoverPresentationController?.sourceView = currentVc.view
picker.modalPresentationStyle = .pageSheet
}
currentVc.present(picker, animated: true)
}
func didPickFileAt(url: URL) {
handlePickingResult { context in
let file = FileSystemFile(url: url)
context.promise.resolve(file)
}
}
func didPickDirectoryAt(url: URL) {
handlePickingResult { context in
let directory = FileSystemDirectory(url: url)
context.promise.resolve(directory)
}
}
func didCancelPicking() {
handlePickingResult { context in
context.promise.reject(FilePickingCancelledException())
}
}
private func handlePickingResult(_ handler: (FilePickingContext) -> Void) {
guard let context = filePickingContext else {
return
}
filePickingContext = nil
handler(context)
}
}
#endif

View File

@@ -0,0 +1,167 @@
// UIDocumentPickerViewController is unavailable on tvOS
#if os(iOS)
import ExpoModulesCore
import MobileCoreServices
import UIKit
import UniformTypeIdentifiers
internal protocol FilePickingResultHandler {
func didPickFileAt(url: URL)
func didPickDirectoryAt(url: URL)
func didCancelPicking()
}
internal struct FilePickingContext {
let promise: Promise
let initialUri: URL?
let mimeType: String?
let isDirectory: Bool
let delegate: FilePickingDelegate
var pickedUrl: URL?
}
internal class FilePickingDelegate: NSObject, UIDocumentPickerDelegate, UIAdaptivePresentationControllerDelegate {
private let resultHandler: FilePickingResultHandler
private let isDirectory: Bool
private weak var pickingHandler: FilePickingHandler?
init(resultHandler: FilePickingResultHandler, isDirectory: Bool = false) {
self.resultHandler = resultHandler
self.isDirectory = isDirectory
self.pickingHandler = resultHandler as? FilePickingHandler
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else {
self.resultHandler.didCancelPicking()
return
}
if isDirectory {
// For directory access, we need to start accessing the security-scoped resource
let didStartAccessing = url.startAccessingSecurityScopedResource()
if didStartAccessing {
// Store the picked URL for proper cleanup
if let pickingHandler = pickingHandler {
pickingHandler.filePickingContext?.pickedUrl = url
}
self.resultHandler.didPickDirectoryAt(url: url)
} else {
// If we can't access the directory, treat as cancellation
self.resultHandler.didCancelPicking()
}
} else {
self.resultHandler.didPickFileAt(url: url)
}
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
self.resultHandler.didCancelPicking()
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
self.resultHandler.didCancelPicking()
}
}
internal func createFilePicker(initialUri: URL?, mimeType: String?) -> UIDocumentPickerViewController {
if #available(iOS 14.0, *) {
let utTypes: [UTType]
if let mimeType = mimeType {
if let utType = UTType(mimeType: mimeType) {
utTypes = [utType]
} else {
utTypes = [UTType.item]
}
} else {
utTypes = [UTType.item]
}
let picker = UIDocumentPickerViewController(forOpeningContentTypes: utTypes, asCopy: true)
if let initialUri = initialUri {
picker.directoryURL = initialUri
}
return picker
}
let utiTypes: [String]
if let mimeType = mimeType {
utiTypes = [toUTI(mimeType: mimeType)]
} else {
utiTypes = [kUTTypeItem as String]
}
let picker = UIDocumentPickerViewController(documentTypes: utiTypes, in: .import)
if let initialUri = initialUri {
picker.directoryURL = initialUri
}
return picker
}
internal func createDirectoryPicker(initialUri: URL?) -> UIDocumentPickerViewController {
if #available(iOS 14.0, *) {
// Use UTType.folder for directory access as per Apple's documentation
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.folder], asCopy: false)
if let initialUri = initialUri {
picker.directoryURL = initialUri
}
return picker
}
// For iOS 13 and earlier, use kUTTypeFolder
let picker = UIDocumentPickerViewController(documentTypes: [kUTTypeFolder as String], in: .open)
if let initialUri = initialUri {
picker.directoryURL = initialUri
}
return picker
}
@available(iOS 14.0, *)
private func toUTType(mimeType: String) -> UTType? {
switch mimeType {
case "*/*":
return UTType.item
case "image/*":
return UTType.image
case "video/*":
return UTType.movie
case "audio/*":
return UTType.audio
case "text/*":
return UTType.text
default:
return UTType(mimeType: mimeType)
}
}
private func toUTI(mimeType: String) -> String {
var uti: CFString
switch mimeType {
case "*/*":
uti = kUTTypeItem
case "image/*":
uti = kUTTypeImage
case "video/*":
uti = kUTTypeVideo
case "audio/*":
uti = kUTTypeAudio
case "text/*":
uti = kUTTypeText
default:
if let ref = UTTypeCreatePreferredIdentifierForTag(
kUTTagClassMIMEType,
mimeType as CFString,
nil
)?.takeRetainedValue() {
uti = ref
} else {
uti = kUTTypeItem
}
}
return uti as String
}
#endif

View File

@@ -0,0 +1,112 @@
import Foundation
import ExpoModulesCore
internal final class FileSystemDirectory: FileSystemPath {
init(url: URL) {
super.init(url: url, isDirectory: true)
}
override func validateType() throws {
try withCorrectTypeAndScopedAccess(permission: .read) {
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) {
if !isDirectory.boolValue {
throw InvalidTypeDirectoryException()
}
}
}
}
func create(_ options: CreateOptions) throws {
try withCorrectTypeAndScopedAccess(permission: .write) {
guard try needsCreation(options) else {
return
}
try validateCanCreate(options)
do {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: options.intermediates, attributes: nil)
} catch {
throw UnableToCreateException(error.localizedDescription)
}
}
}
var size: Int64 {
get throws {
try validatePermission(.read)
var size: Int64 = 0
guard let subpaths = try? FileManager.default.subpathsOfDirectory(atPath: url.path) else {
throw UnableToGetSizeException("attributes do not contain size")
}
for subpath in subpaths {
let strSubpath = url.appendingPathComponent(subpath).path
guard let attributes: [FileAttributeKey: Any] = try? FileManager.default.attributesOfItem(atPath: strSubpath), let subpathSize = attributes[.size] as? Int64 else {
continue
}
size += subpathSize
}
return size
}
}
override var exists: Bool {
guard checkPermission(.read) else {
return false
}
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) {
return isDirectory.boolValue
}
return false
}
// Internal only function
func listAsRecords() throws -> [[String: Any]] {
try withCorrectTypeAndScopedAccess(permission: .read) {
var contents: [[String: Any]] = []
let items = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
for item in items {
contents.append(["isDirectory": item.hasDirectoryPath, "uri": item.absoluteString])
}
return contents
}
}
func validatePath() throws {
guard url.isFileURL && url.hasDirectoryPath else {
throw Exception(name: "wrong type", description: "tried to create a directory with a file path")
}
}
func info() throws -> DirectoryInfo {
try withCorrectTypeAndScopedAccess(permission: .read) {
if !exists {
let result = DirectoryInfo()
result.exists = false
result.uri = url.absoluteString
return result
}
switch url.scheme {
case "file":
let result = DirectoryInfo()
result.exists = true
result.uri = url.absoluteString
result.size = try size
result.files = (try? FileManager.default.contentsOfDirectory(atPath: url.path)) ?? []
result.modificationTime = try modificationTime
result.creationTime = try creationTime
return result
default:
throw UnableToGetInfoException("url scheme \(String(describing: url.scheme)) is not supported")
}
}
}
func needsCreation(_ options: CreateOptions) throws -> Bool {
if !exists {
return true
}
return !options.idempotent
}
}

View File

@@ -0,0 +1,110 @@
import Foundation
import ExpoModulesCore
internal final class CopyOrMoveDirectoryToFileException: Exception {
override var reason: String {
"Unable to copy or move a directory to a file"
}
}
internal final class UnableToDownloadException: GenericException<String> {
override var reason: String {
"Unable to download a file: \(param)"
}
}
internal final class UnableToWriteBase64DataException: GenericException<String> {
override var reason: String {
"Unable to write base64 data to a file: \(param)"
}
}
internal final class InvalidTypeFileException: Exception {
override var reason: String {
"A folder with the same name already exists in the file location"
}
}
internal final class InvalidTypeDirectoryException: Exception {
override var reason: String {
"A file with the same name already exists in the directory location"
}
}
internal final class UnableToGetFileAttribute: GenericException<String> {
override var reason: String {
"Unable to get file attribute: \(param)"
}
}
internal final class UnableToGetSizeException: GenericException<String> {
override var reason: String {
"Unable to get file or directory size: \(param)"
}
}
internal final class UnableToDeleteException: GenericException<String> {
override var reason: String {
"Unable to delete file or directory: \(param)"
}
}
internal final class UnableToCreateException: GenericException<String> {
override var reason: String {
"Unable to create file or directory: \(param)"
}
}
internal final class UnableToReadHandleException: GenericException<String> {
override var reason: String {
"Unable to read from a file handle: \(param)"
}
}
internal final class UnableToGetInfoException: GenericException<String> {
override var reason: String {
"Unable to get info from a file: \(param)"
}
}
internal final class DestinationAlreadyExistsException: Exception {
override var reason: String {
"Destination already exists"
}
}
internal final class MissingPermissionException: GenericException<String> {
override var reason: String {
"Missing permission for uri: \(param)"
}
}
internal final class PickingInProgressException: Exception {
override var reason: String {
"File picking is already in progress"
}
}
internal final class MissingViewControllerException: Exception {
override var reason: String {
"No view controller available for presenting file picker"
}
}
internal final class FilePickingCancelledException: Exception {
override var reason: String {
"File picking was cancelled by the user"
}
}
internal final class NotImplementedException: Exception {
override var reason: String {
"Not implemented"
}
}
internal final class FeatureNotAvailableOnPlatformException: Exception {
override var reason: String {
"This feature is not available on this platform"
}
}

166
node_modules/expo-file-system/ios/FileSystemFile.swift generated vendored Normal file
View File

@@ -0,0 +1,166 @@
import Foundation
import ExpoModulesCore
import CryptoKit
import UniformTypeIdentifiers
internal final class FileSystemFile: FileSystemPath {
init(url: URL) {
super.init(url: url, isDirectory: false)
}
override func validateType() throws {
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) {
if isDirectory.boolValue {
throw InvalidTypeFileException()
}
}
}
func create(_ options: CreateOptions) throws {
try withCorrectTypeAndScopedAccess(permission: .write) {
try validateCanCreate(options)
do {
if options.intermediates {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
}
try? FileManager.default.removeItem(atPath: url.path)
FileManager.default.createFile(atPath: url.path, contents: nil)
} catch {
throw UnableToCreateException(error.localizedDescription)
}
}
}
override var exists: Bool {
guard checkPermission(.read) else {
return false
}
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) {
return !isDirectory.boolValue
}
return false
}
// TODO: Move to the constructor once error is rethrowed
func validatePath() throws {
guard url.isFileURL && !url.hasDirectoryPath else {
throw Exception(name: "wrong type", description: "tried to create a file with a directory path")
}
}
var md5: String {
get throws {
return try withCorrectTypeAndScopedAccess(permission: .read) {
let fileData = try Data(contentsOf: url)
let hash = Insecure.MD5.hash(data: fileData)
return hash.map { String(format: "%02hhx", $0) }.joined()
}
}
}
var size: Int64 {
get throws {
return try getAttribute(.size, atPath: url.path)
}
}
var type: String? {
let pathExtension = url.pathExtension
if let utType = UTType(filenameExtension: pathExtension),
let mimeType = utType.preferredMIMEType {
return mimeType
}
return nil
}
func write(_ content: String, append: Bool = false) throws {
try withCorrectTypeAndScopedAccess(permission: .write) {
if append, let data = content.data(using: .utf8) {
try writeAppending(data)
} else {
try content.write(to: url, atomically: false, encoding: .utf8) // TODO: better error handling
}
}
}
func write(_ data: Data, append: Bool = false) throws {
try withCorrectTypeAndScopedAccess(permission: .write) {
if append {
try writeAppending(data)
} else {
try data.write(to: url)
}
}
}
// TODO: blob support
func write(_ content: TypedArray, append: Bool = false) throws {
try withCorrectTypeAndScopedAccess(permission: .write) {
let data = Data(bytes: content.rawPointer, count: content.byteLength)
if append {
try writeAppending(data)
} else {
try data.write(to: url)
}
}
}
private func writeAppending(_ data: Data) throws {
if !FileManager.default.fileExists(atPath: url.path) {
try data.write(to: url)
return
}
let fileHandle = try FileHandle(forWritingTo: url)
defer {
fileHandle.closeFile()
}
fileHandle.seekToEndOfFile()
fileHandle.write(data)
}
func text() throws -> String {
return try withCorrectTypeAndScopedAccess(permission: .write) {
return try String(contentsOf: url)
}
}
func bytes() throws -> Data {
return try withCorrectTypeAndScopedAccess(permission: .write) {
return try Data(contentsOf: url)
}
}
func base64() throws -> String {
return try withCorrectTypeAndScopedAccess(permission: .read) {
return try Data(contentsOf: url).base64EncodedString()
}
}
func info(options: InfoOptions) throws -> FileInfo {
return try withCorrectTypeAndScopedAccess(permission: .read) {
if !exists {
let result = FileInfo()
result.exists = false
result.uri = url.absoluteString
return result
}
switch url.scheme {
case "file":
let result = FileInfo()
result.exists = true
result.uri = url.absoluteString
result.size = try size
result.modificationTime = try modificationTime
result.creationTime = try creationTime
if options.md5 {
result.md5 = try md5
}
return result
default:
throw UnableToGetInfoException("url scheme \(String(describing: url.scheme)) is not supported")
}
}
}
}

View File

@@ -0,0 +1,54 @@
import Foundation
import ExpoModulesCore
@available(iOS 14, tvOS 14, *)
internal final class FileSystemFileHandle: SharedRef<FileHandle> {
let file: FileSystemFile
let handle: FileHandle
init(file: FileSystemFile) throws {
self.file = file
handle = try FileHandle(forUpdating: file.url)
super.init(handle)
}
func read(_ length: Int) throws -> Data {
do {
let data = try handle.read(upToCount: length)
return data ?? Data()
} catch {
throw UnableToReadHandleException(error.localizedDescription)
}
}
func write(_ bytes: Data) throws {
try handle.write(contentsOf: bytes)
}
func close() throws {
try handle.close()
}
var offset: UInt64? {
get {
try? handle.offset()
}
set(newOffset) {
guard let newOffset else {
return
}
handle.seek(toFileOffset: newOffset)
}
}
var size: UInt64? {
do {
let offset = try handle.offset()
let size = try handle.seekToEnd()
handle.seek(toFileOffset: offset)
return size
} catch {
return nil
}
}
}

View File

@@ -0,0 +1,373 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import ExpoModulesCore
@available(iOS 14, tvOS 14, *)
public final class FileSystemModule: Module {
#if os(iOS)
private lazy var filePickingHandler = FilePickingHandler(module: self)
#endif
var documentDirectory: URL? {
return appContext?.config.documentDirectory
}
var cacheDirectory: URL? {
return appContext?.config.cacheDirectory
}
var totalDiskSpace: Int64? {
guard let path = documentDirectory?.path,
let attributes = try? FileManager.default.attributesOfFileSystem(forPath: path) else {
return nil
}
return attributes[.systemFreeSize] as? Int64
}
var availableDiskSpace: Int64? {
guard let path = documentDirectory?.path,
let attributes = try? FileManager.default.attributesOfFileSystem(forPath: path) else {
return nil
}
return attributes[.systemFreeSize] as? Int64
}
public func definition() -> ModuleDefinition {
Name("FileSystem")
Constant("documentDirectory") {
return documentDirectory?.absoluteString
}
Constant("cacheDirectory") {
return cacheDirectory?.absoluteString
}
Constant("bundleDirectory") {
return Bundle.main.bundlePath
}
Constant("appleSharedContainers") {
return getAppleSharedContainers()
}
Property("totalDiskSpace") {
return totalDiskSpace
}
Property("availableDiskSpace") {
return availableDiskSpace
}
// swiftlint:disable:next closure_body_length
AsyncFunction("downloadFileAsync") { (url: URL, to: FileSystemPath, options: DownloadOptions?, promise: Promise) in
try to.validatePermission(.write)
var request = URLRequest(url: url)
if let headers = options?.headers {
headers.forEach { key, value in
request.addValue(value, forHTTPHeaderField: key)
}
}
let downloadTask = URLSession.shared.downloadTask(with: request) { urlOrNil, responseOrNil, errorOrNil in
guard errorOrNil == nil else {
return promise.reject(UnableToDownloadException(errorOrNil?.localizedDescription ?? "unspecified error"))
}
guard let httpResponse = responseOrNil as? HTTPURLResponse else {
return promise.reject(UnableToDownloadException("no response"))
}
guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
return promise.reject(UnableToDownloadException("response has status \(httpResponse.statusCode)"))
}
guard let fileURL = urlOrNil else {
return promise.reject(UnableToDownloadException("no file url"))
}
do {
let destination: URL
if let to = to as? FileSystemDirectory {
let filename = httpResponse.suggestedFilename ?? url.lastPathComponent
destination = to.url.appendingPathComponent(filename)
} else {
destination = to.url
}
if FileManager.default.fileExists(atPath: destination.path) {
if options?.idempotent == true {
try FileManager.default.removeItem(at: destination)
} else {
throw DestinationAlreadyExistsException()
}
}
try FileManager.default.moveItem(at: fileURL, to: destination)
// TODO: Remove .url.absoluteString once returning shared objects works
promise.resolve(destination.absoluteString)
} catch {
promise.reject(error)
}
}
downloadTask.resume()
}
AsyncFunction("pickDirectoryAsync") { (initialUri: URL?, promise: Promise) in
#if os(iOS)
filePickingHandler.presentDocumentPicker(
picker: createDirectoryPicker(initialUri: initialUri),
isDirectory: true,
initialUri: initialUri,
mimeType: nil,
promise: promise
)
#else
promise.reject(FeatureNotAvailableOnPlatformException())
#endif
}.runOnQueue(.main)
AsyncFunction("pickFileAsync") { (initialUri: URL?, mimeType: String?, promise: Promise) in
#if os(iOS)
filePickingHandler.presentDocumentPicker(
picker: createFilePicker(initialUri: initialUri, mimeType: mimeType),
isDirectory: false,
initialUri: initialUri,
mimeType: mimeType,
promise: promise
)
#else
promise.reject(FeatureNotAvailableOnPlatformException())
#endif
}.runOnQueue(.main)
Function("info") { (url: URL) in
let output = PathInfo()
output.exists = false
output.isDirectory = nil
guard let fileSystemManager = appContext?.fileSystem else {
return output
}
if fileSystemManager.getPathPermissions(url.path).contains(.read) {
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) {
output.exists = true
output.isDirectory = isDirectory.boolValue
return output
}
}
return output
}
// swiftlint:disable:next closure_body_length
Class(FileSystemFile.self) {
Constructor { (url: URL) in
return FileSystemFile(url: url.standardizedFileURL)
}
// we can't throw in a constructor, so this is a workaround
Function("validatePath") { file in
try file.validatePath()
}
// maybe asString, readAsString, readAsText, readText, ect.
AsyncFunction("text") { file in
return try file.text()
}
Function("textSync") { file in
return try file.text()
}
AsyncFunction("base64") { file in
return try file.base64()
}
Function("base64Sync") { file in
return try file.base64()
}
AsyncFunction("bytes") { file in
return try file.bytes()
}
Function("bytesSync") { file in
return try file.bytes()
}
Function("open") { file in
return try FileSystemFileHandle(file: file)
}
Function("info") { (file: FileSystemFile, options: InfoOptions?) in
return try file.info(options: options ?? InfoOptions())
}
Function("write") { (file: FileSystemFile, content: Either<String, TypedArray>, options: WriteOptions?) in
let append = options?.append ?? false
if let content: String = content.get() {
if options?.encoding == WriteEncoding.base64 {
guard let data = Data(base64Encoded: content, options: .ignoreUnknownCharacters) else {
throw UnableToWriteBase64DataException(file.url.absoluteString)
}
try file.write(data, append: append)
} else {
try file.write(content, append: append)
}
}
if let content: TypedArray = content.get() {
try file.write(content, append: append)
}
}
Property("size") { file in
try? file.size
}
Property("md5") { file in
try? file.md5
}
Property("modificationTime") { file in
try? file.modificationTime
}
Property("creationTime") { file in
try? file.creationTime
}
Property("type") { file in
file.type
}
Function("delete") { file in
try file.delete()
}
Property("exists") { file in
return file.exists
}
Function("create") { (file, options: CreateOptions?) in
try file.create(options ?? CreateOptions())
}
Function("copy") { (file, to: FileSystemPath) in
try file.copy(to: to)
}
Function("move") { (file, to: FileSystemPath) in
try file.move(to: to)
}
Function("rename") { (file, newName: String) in
try file.rename(newName)
}
Property("uri") { file in
return file.url.absoluteString
}
}
Class(FileSystemFileHandle.self) {
Function("readBytes") { (fileHandle, bytes: Int) in
try fileHandle.read(bytes)
}
Function("writeBytes") { (fileHandle, bytes: Data) in
try fileHandle.write(bytes)
}
Function("close") { fileHandle in
try fileHandle.close()
}
Property("offset") { fileHandle in
fileHandle.offset
}.set { (fileHandle, volume: UInt64) in
fileHandle.offset = volume
}
Property("size") { fileHandle in
fileHandle.size
}
}
// swiftlint:disable:next closure_body_length
Class(FileSystemDirectory.self) {
Constructor { (url: URL) in
return FileSystemDirectory(url: url.standardizedFileURL)
}
Function("info") { directory in
try directory.info()
}
// we can't throw in a constructor, so this is a workaround
Function("validatePath") { directory in
try directory.validatePath()
}
Function("delete") { directory in
try directory.delete()
}
Property("exists") { directory in
return directory.exists
}
Function("create") { (directory, options: CreateOptions?) in
try directory.create(options ?? CreateOptions())
}
Function("copy") { (directory, to: FileSystemPath) in
try directory.copy(to: to)
}
Function("move") { (directory, to: FileSystemPath) in
try directory.move(to: to)
}
Function("rename") { (directory, newName: String) in
try directory.rename(newName)
}
// this function is internal and will be removed in the future (when returning arrays of shared objects is supported)
Function("listAsRecords") { directory in
try directory.listAsRecords()
}
Function("createFile") { (directory, name: String, content: String?) in
let file = FileSystemFile(url: directory.url.appendingPathComponent(name))
try file.create(CreateOptions())
return file
}
Function("createDirectory") { (directory, name: String) in
let newDirectory = FileSystemDirectory(url: directory.url.appendingPathComponent(name))
try newDirectory.create(CreateOptions())
return newDirectory
}
Property("uri") { directory in
return directory.url.absoluteString
}
Property("size") { directory in
return try? directory.size
}
}
}
private func getAppleSharedContainers() -> [String: String] {
guard let appContext else {
return [:]
}
var result: [String: String] = [:]
for appGroup in appContext.appCodeSignEntitlements.appGroups ?? [] {
if let directory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) {
result[appGroup] = directory.standardizedFileURL.path
}
}
return result
}
}

135
node_modules/expo-file-system/ios/FileSystemPath.swift generated vendored Normal file
View File

@@ -0,0 +1,135 @@
import Foundation
import ExpoModulesCore
internal class FileSystemPath: SharedObject {
var url: URL
func validateType() throws {
throw NotImplementedException()
}
init(url: URL, isDirectory: Bool) {
let standardizedUrl = url.deletingLastPathComponent().appendingPathComponent(url.lastPathComponent, isDirectory: isDirectory)
self.url = standardizedUrl
}
func validatePermission(_ flag: FileSystemPermissionFlags) throws {
if !checkPermission(flag) {
throw MissingPermissionException(url.absoluteString)
}
}
func checkPermission(_ flag: FileSystemPermissionFlags) -> Bool {
return FileSystemUtilities.permissions(appContext, for: url).contains(flag)
}
func validateCanCreate(_ options: CreateOptions) throws {
if try !options.overwrite && exists {
throw FileAlreadyExistsException(url.absoluteString)
}
}
var exists: Bool {
get throws {
FileManager.default.fileExists(atPath: url.path)
}
}
func delete() throws {
try validatePermission(.write)
guard FileManager.default.fileExists(atPath: url.path) else {
throw UnableToDeleteException("path does not exist")
}
do {
try FileManager.default.removeItem(at: url)
} catch {
throw UnableToDeleteException(error.localizedDescription)
}
}
func getMoveOrCopyPath(to destination: FileSystemPath) throws -> URL {
if let destination = destination as? FileSystemDirectory {
if self is FileSystemFile {
return destination.url.appendingPathComponent(url.lastPathComponent)
}
// self if FileSystemDirectory
// we match unix behavior https://askubuntu.com/a/763915
if destination.exists {
return destination.url.appendingPathComponent(url.lastPathComponent, isDirectory: true)
}
return destination.url
}
// destination is FileSystemFile
guard self is FileSystemFile else {
throw CopyOrMoveDirectoryToFileException()
}
return destination.url
}
func copy(to destination: FileSystemPath) throws {
try validatePermission(.read)
try destination.validatePermission(.write)
try FileManager.default.copyItem(at: url, to: getMoveOrCopyPath(to: destination))
}
func move(to destination: FileSystemPath) throws {
try validatePermission(.write)
try destination.validatePermission(.write)
let destinationUrl = try getMoveOrCopyPath(to: destination)
try FileManager.default.moveItem(at: url, to: destinationUrl)
url = destinationUrl
}
func getRenamedUrl(newName: String) -> URL {
return url.deletingLastPathComponent().appendingPathComponent(newName)
}
func rename(_ newName: String) throws {
try validatePermission(.write)
let newUrl = getRenamedUrl(newName: newName)
try FileManager.default.moveItem(at: url, to: newUrl)
// Refetch the URL to ensure it has the correct trailing slash, which differs for directories and files.
let updatedUrl = getRenamedUrl(newName: newName)
url = updatedUrl
}
var modificationTime: Int64 {
get throws {
let modificationDate: Date = try getAttribute(.modificationDate, atPath: url.path)
return Int64(modificationDate.timeIntervalSince1970 * 1000)
}
}
var creationTime: Int64 {
get throws {
let creationDate: Date = try getAttribute(.creationDate, atPath: url.path)
return Int64(creationDate.timeIntervalSince1970 * 1000)
}
}
internal func getAttribute<T>(_ key: FileAttributeKey, atPath path: String) throws -> T {
try validatePermission(.read)
let attributes = try FileManager.default.attributesOfItem(atPath: path)
guard let attribute = attributes[key] else {
throw UnableToGetFileAttribute("attributes do not contain \(key)")
}
guard let attributeCasted = attribute as? T else {
throw UnableToGetFileAttribute("\(key) is not of expected type")
}
return attributeCasted
}
@discardableResult
func withCorrectTypeAndScopedAccess<T>(
permission: FileSystemPermissionFlags,
_ work: () throws -> T
) throws -> T {
let accessed = url.startAccessingSecurityScopedResource()
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
try validatePermission(permission)
return try work()
}
}

View File

@@ -0,0 +1,47 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import ExpoModulesCore
struct CreateOptions: Record {
@Field var intermediates: Bool = false
@Field var overwrite: Bool = false
@Field var idempotent: Bool = false
}
struct DownloadOptions: Record {
@Field var headers: [String: String]?
@Field var idempotent: Bool = false
}
struct FileInfo: Record {
@Field var exists: Bool
@Field var uri: String?
@Field var md5: String?
@Field var size: Int64?
@Field var modificationTime: Int64?
@Field var creationTime: Int64?
}
struct PathInfo: Record {
@Field var exists: Bool
@Field var isDirectory: Bool?
}
struct DirectoryInfo: Record {
@Field var exists: Bool
@Field var uri: String?
@Field var files: [String]?
@Field var size: Int64?
@Field var modificationTime: Int64?
@Field var creationTime: Int64?
}
enum WriteEncoding: String, Enumerable {
case utf8
case base64
}
struct WriteOptions: Record {
@Field var encoding: WriteEncoding?
@Field var append: Bool = false
}

View File

@@ -0,0 +1,6 @@
#import <ExpoFileSystem/EXFileSystemHandler.h>
@interface EXFileSystemAssetLibraryHandler : NSObject <EXFileSystemHandler>
@end

View File

@@ -0,0 +1,147 @@
#import <ExpoFileSystem/EXFileSystemAssetLibraryHandler.h>
#import <ExpoFileSystem/NSData+EXFileSystem.h>
#import <Photos/Photos.h>
@implementation EXFileSystemAssetLibraryHandler
+ (void)getInfoForFile:(NSURL *)fileUri
withOptions:(NSDictionary *)options
resolver:(EXPromiseResolveBlock)resolve
rejecter:(EXPromiseRejectBlock)reject
{
NSError *error;
PHFetchResult<PHAsset *> *fetchResult = [self fetchResultForUri:fileUri error:&error];
if (error) {
reject(@"E_UNSUPPORTED_ARG", error.description, error);
return;
}
if (fetchResult.count > 0) {
PHAsset *asset = fetchResult[0];
NSMutableDictionary *result = [NSMutableDictionary dictionary];
result[@"exists"] = @(YES);
result[@"isDirectory"] = @(NO);
result[@"uri"] = fileUri;
// Uses required reason API based on the following reason: 3B52.1
result[@"modificationTime"] = @(asset.modificationDate.timeIntervalSince1970);
if (options[@"md5"] || options[@"size"]) {
[[PHImageManager defaultManager] requestImageDataAndOrientationForAsset:asset options:nil resultHandler:^(NSData * _Nullable imageData, NSString * _Nullable dataUTI, CGImagePropertyOrientation orientation, NSDictionary * _Nullable info) {
result[@"size"] = @(imageData.length);
if (options[@"md5"]) {
result[@"md5"] = [imageData md5String];
}
resolve(result);
}];
} else {
resolve(result);
}
} else {
resolve(@{@"exists": @(NO), @"isDirectory": @(NO)});
}
}
+ (void)copyFrom:(NSURL *)from
to:(NSURL *)to
resolver:(EXPromiseResolveBlock)resolve
rejecter:(EXPromiseRejectBlock)reject
{
NSString *toPath = [to.path stringByStandardizingPath];
// NOTE: The destination-delete and the copy should happen atomically, but we hope for the best for now
NSError *error;
if ([[NSFileManager defaultManager] fileExistsAtPath:toPath]) {
if (![[NSFileManager defaultManager] removeItemAtPath:toPath error:&error]) {
reject(@"E_FILE_NOT_COPIED",
[NSString stringWithFormat:@"File '%@' could not be copied to '%@' because a file already exists at "
"the destination and could not be deleted.", from, to],
error);
return;
}
}
PHFetchResult<PHAsset *> *fetchResult = [self fetchResultForUri:from error:&error];
if (error) {
reject(@"E_UNSUPPORTED_ARG", error.description, error);
return;
}
if (fetchResult.count > 0) {
PHAsset *asset = fetchResult[0];
if (asset.mediaType == PHAssetMediaTypeVideo) {
[[PHImageManager defaultManager] requestAVAssetForVideo:asset options:nil resultHandler:^(AVAsset *asset, AVAudioMix *audioMix, NSDictionary *info) {
if (![asset isKindOfClass:[AVURLAsset class]]) {
reject(@"ERR_INCORRECT_ASSET_TYPE",
[NSString stringWithFormat:@"File '%@' has incorrect asset type.", from],
nil);
return;
}
AVURLAsset* urlAsset = (AVURLAsset*)asset;
NSNumber *size;
[urlAsset.URL getResourceValue:&size forKey:NSURLFileSizeKey error:nil];
NSData *data = [NSData dataWithContentsOfURL:urlAsset.URL];
[EXFileSystemAssetLibraryHandler copyData:data toPath:toPath resolver:resolve rejecter:reject];
}];
} else {
[[PHImageManager defaultManager] requestImageDataAndOrientationForAsset:asset options:nil resultHandler:^(NSData * _Nullable imageData, NSString * _Nullable dataUTI, CGImagePropertyOrientation orientation, NSDictionary * _Nullable info) {
[EXFileSystemAssetLibraryHandler copyData:imageData toPath:toPath resolver:resolve rejecter:reject];
}];
}
} else {
reject(@"E_FILE_NOT_COPIED",
[NSString stringWithFormat:@"File '%@' could not be found.", from],
error);
}
}
// adapted from RCTImageLoader.m
+ (PHFetchResult<PHAsset *> *)fetchResultForUri:(NSURL *)url error:(NSError **)error
{
if ([url.scheme caseInsensitiveCompare:@"ph"] == NSOrderedSame) {
// Fetch assets using PHAsset localIdentifier (recommended)
NSString *const localIdentifier = [url.absoluteString substringFromIndex:@"ph://".length];
return [PHAsset fetchAssetsWithLocalIdentifiers:@[localIdentifier] options:nil];
} else if ([url.scheme caseInsensitiveCompare:@"assets-library"] == NSOrderedSame) {
#if TARGET_OS_MACCATALYST
static BOOL hasWarned = NO;
if (!hasWarned) {
NSLog(@"assets-library:// URLs have been deprecated and cannot be accessed in macOS Catalyst. Returning nil (future warnings will be suppressed).");
hasWarned = YES;
}
return nil;
#elif TARGET_OS_IOS || TARGET_OS_TV
// This is the older, deprecated way of fetching assets from assets-library
// using the "assets-library://" protocol
return [PHAsset fetchAssetsWithALAssetURLs:@[url] options:nil];
#elif TARGET_OS_OSX
return nil;
#endif
}
NSString *description = [NSString stringWithFormat:@"Invalid URL provided, expected scheme to be either 'ph' or 'assets-library', was '%@'.", url.scheme];
if (error != NULL) {
*error = [[NSError alloc] initWithDomain:NSURLErrorDomain
code:NSURLErrorUnsupportedURL
userInfo:@{NSLocalizedDescriptionKey: description}];
}
return nil;
}
+ (void)copyData:(NSData *)data
toPath:(NSString *)path
resolver:(EXPromiseResolveBlock)resolve
rejecter:(EXPromiseRejectBlock)reject
{
if ([data writeToFile:path atomically:YES]) {
resolve(nil);
} else {
reject(@"E_FILE_NOT_COPIED",
[NSString stringWithFormat:@"File could not be copied to '%@'.", path],
nil);
}
}
@end

View File

@@ -0,0 +1,17 @@
// Copyright 2023-present 650 Industries. All rights reserved.
#import <ExpoModulesCore/ExpoModulesCore.h>
@protocol EXFileSystemHandler
+ (void)getInfoForFile:(NSURL *)fileUri
withOptions:(NSDictionary *)optionxs
resolver:(EXPromiseResolveBlock)resolve
rejecter:(EXPromiseRejectBlock)reject;
+ (void)copyFrom:(NSURL *)from
to:(NSURL *)to
resolver:(EXPromiseResolveBlock)resolve
rejecter:(EXPromiseRejectBlock)reject;
@end

View File

@@ -0,0 +1,7 @@
// Copyright 2023-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXFileSystemHandler.h>
@interface EXFileSystemLocalFileHandler : NSObject <EXFileSystemHandler>
@end

View File

@@ -0,0 +1,80 @@
#import <ExpoFileSystem/EXFileSystemLocalFileHandler.h>
#import <ExpoFileSystem/NSData+EXFileSystem.h>
@implementation EXFileSystemLocalFileHandler
+ (void)getInfoForFile:(NSURL *)fileUri
withOptions:(NSDictionary *)options
resolver:(EXPromiseResolveBlock)resolve
rejecter:(EXPromiseRejectBlock)reject
{
NSString *path = fileUri.path;
BOOL isDirectory;
if ([[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDirectory]) {
NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
NSMutableDictionary *result = [NSMutableDictionary dictionary];
result[@"exists"] = @(YES);
result[@"isDirectory"] = @(isDirectory);
result[@"uri"] = [NSURL fileURLWithPath:path].absoluteString;
if ([options[@"md5"] boolValue]) {
result[@"md5"] = [[NSData dataWithContentsOfFile:path] md5String];
}
result[@"size"] = @([EXFileSystemLocalFileHandler getFileSize:path attributes:attributes]);
// Uses required reason API based on the following reason: 0A2A.1
result[@"modificationTime"] = @(attributes.fileModificationDate.timeIntervalSince1970);
resolve(result);
} else {
resolve(@{@"exists": @(NO), @"isDirectory": @(NO)});
}
}
+ (unsigned long long)getFileSize:(NSString *)path attributes:(NSDictionary<NSFileAttributeKey, id> *)attributes
{
if (attributes.fileType != NSFileTypeDirectory) {
return attributes.fileSize;
}
// The path is pointing to the folder
NSArray *contents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:path error:nil];
NSEnumerator *contentsEnumurator = [contents objectEnumerator];
NSString *file;
unsigned long long folderSize = 0;
while (file = [contentsEnumurator nextObject]) {
NSString *filePath = [path stringByAppendingPathComponent:file];
NSDictionary *fileAttributes = [[NSFileManager defaultManager] attributesOfItemAtPath:filePath error:nil];
folderSize += [EXFileSystemLocalFileHandler getFileSize:filePath attributes:fileAttributes];
}
return folderSize;
}
+ (void)copyFrom:(NSURL *)from
to:(NSURL *)to
resolver:(EXPromiseResolveBlock)resolve
rejecter:(EXPromiseRejectBlock)reject
{
NSString *fromPath = [from.path stringByStandardizingPath];
NSString *toPath = [to.path stringByStandardizingPath];
NSError *error;
if ([[NSFileManager defaultManager] fileExistsAtPath:toPath]) {
if (![[NSFileManager defaultManager] removeItemAtPath:toPath error:&error]) {
reject(@"E_FILE_NOT_COPIED",
[NSString stringWithFormat:@"File '%@' could not be copied to '%@' because a file already exists at "
"the destination and could not be deleted.", from, to],
error);
return;
}
}
if ([[NSFileManager defaultManager] copyItemAtPath:fromPath toPath:toPath error:&error]) {
resolve(nil);
} else {
reject(@"E_FILE_NOT_COPIED",
[NSString stringWithFormat:@"File '%@' could not be copied to '%@'.", from, to],
error);
}
}
@end

View File

@@ -0,0 +1,16 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXSessionUploadTaskDelegate.h>
#import <ExpoFileSystem/EXTaskHandlersManager.h>
typedef void (^EXUploadDelegateOnSendCallback)(NSURLSessionUploadTask *task, int64_t bytesSent, int64_t totalBytesSent, int64_t totalBytesExpectedToSend);
@interface EXSessionCancelableUploadTaskDelegate : EXSessionUploadTaskDelegate
- (nonnull instancetype)initWithResolve:(EXPromiseResolveBlock)resolve
reject:(EXPromiseRejectBlock)reject
onSendCallback:(EXUploadDelegateOnSendCallback)onSendCallback
resumableManager:(EXTaskHandlersManager *)manager
uuid:(NSString *)uuid;
@end

View File

@@ -0,0 +1,55 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXSessionCancelableUploadTaskDelegate.h>
@interface EXSessionCancelableUploadTaskDelegate ()
@property (strong, nonatomic, readonly) EXUploadDelegateOnSendCallback onSendCallback;
@property (weak, nonatomic) EXTaskHandlersManager *manager;
@property (strong, nonatomic) NSString *uuid;
@end
@implementation EXSessionCancelableUploadTaskDelegate
- (nonnull instancetype)initWithResolve:(EXPromiseResolveBlock)resolve
reject:(EXPromiseRejectBlock)reject
onSendCallback:(EXUploadDelegateOnSendCallback)onSendCallback
resumableManager:(EXTaskHandlersManager *)manager
uuid:(NSString *)uuid;
{
if (self = [super initWithResolve:resolve
reject:reject]) {
_onSendCallback = onSendCallback;
_manager = manager;
_uuid = uuid;
}
return self;
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
if (error) {
// The task was paused by us. So, we shouldn't throw.
if (error.code == NSURLErrorCancelled) {
self.resolve([NSNull null]);
[_manager unregisterTask:_uuid];
return;
}
}
[super URLSession:session task:task didCompleteWithError:error];
[_manager unregisterTask:_uuid];
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didSendBodyData:(int64_t)bytesSent
totalBytesSent:(int64_t)totalBytesSent
totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend
{
if (_onSendCallback && bytesSent > 0) {
_onSendCallback(task, bytesSent, totalBytesSent, totalBytesExpectedToSend);
}
}
@end

View File

@@ -0,0 +1,13 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXSessionTaskDelegate.h>
@interface EXSessionDownloadTaskDelegate : EXSessionTaskDelegate
- (nonnull instancetype)initWithResolve:(EXPromiseResolveBlock)resolve
reject:(EXPromiseRejectBlock)reject
localUrl:(NSURL *)localUrl
shouldCalculateMd5:(BOOL)shouldCalculateMd5;
@end

View File

@@ -0,0 +1,64 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXSessionDownloadTaskDelegate.h>
#import <ExpoFileSystem/NSData+EXFileSystem.h>
@interface EXSessionDownloadTaskDelegate ()
@property (strong, nonatomic) NSURL *localUrl;
@property (nonatomic) BOOL shouldCalculateMd5;
@end
@implementation EXSessionDownloadTaskDelegate
- (nonnull instancetype)initWithResolve:(EXPromiseResolveBlock)resolve
reject:(EXPromiseRejectBlock)reject
localUrl:(NSURL *)localUrl
shouldCalculateMd5:(BOOL)shouldCalculateMd5
{
if (self = [super initWithResolve:resolve reject:reject])
{
_localUrl = localUrl;
_shouldCalculateMd5 = shouldCalculateMd5;
}
return self;
}
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
{
NSError *error;
NSFileManager *fileManager = [NSFileManager defaultManager];
if ([fileManager fileExistsAtPath:_localUrl.path]) {
[fileManager removeItemAtURL:_localUrl error:&error];
if (error) {
self.reject(@"ERR_FILESYSTEM_CANNOT_REMOVE",
[NSString stringWithFormat:@"Unable to remove file from local URI: '%@'", error.description],
error);
return;
}
}
[fileManager moveItemAtURL:location toURL:_localUrl error:&error];
if (error) {
self.reject(@"ERR_FILESYSTEM_CANNOT_SAVE",
[NSString stringWithFormat:@"Unable to save file to local URI: '%@'", error.description],
error);
return;
}
self.resolve([self parseServerResponse:downloadTask.response]);
}
- (NSDictionary *)parseServerResponse:(NSURLResponse *)response
{
NSMutableDictionary *result = [[super parseServerResponse:response] mutableCopy];
result[@"uri"] = _localUrl.absoluteString;
if (_shouldCalculateMd5) {
NSData *data = [NSData dataWithContentsOfURL:_localUrl];
result[@"md5"] = [data md5String];
}
return result;
}
@end

View File

@@ -0,0 +1,19 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <Foundation/Foundation.h>
#import <ExpoModulesCore/Platform.h>
#import <ExpoModulesCore/EXSingletonModule.h>
NS_ASSUME_NONNULL_BEGIN
@protocol EXSessionHandler
- (void)invokeCompletionHandlerForSessionIdentifier:(NSString *)identifier;
@end
@interface EXSessionHandler : EXSingletonModule <UIApplicationDelegate, EXSessionHandler>
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,49 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXSessionHandler.h>
#import <ExpoModulesCore/EXDefines.h>
@interface EXSessionHandler ()
@property (nonatomic, strong) NSMutableDictionary<NSString *, void (^)(void)> *completionHandlers;
@end
@implementation EXSessionHandler
EX_REGISTER_SINGLETON_MODULE(SessionHandler);
- (instancetype)init
{
if (self = [super init]) {
_completionHandlers = [NSMutableDictionary dictionary];
}
return self;
}
- (void)invokeCompletionHandlerForSessionIdentifier:(NSString *)identifier
{
if (!identifier) {
return;
}
void (^completionHandler)(void) = _completionHandlers[identifier];
if (completionHandler) {
// We need to run completionHandler explicite on the main thread because is's part of UIKit
dispatch_async(dispatch_get_main_queue(), ^{
completionHandler();
});
[_completionHandlers removeObjectForKey:identifier];
}
}
#pragma mark - AppDelegate
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler
{
_completionHandlers[identifier] = completionHandler;
}
@end

View File

@@ -0,0 +1,18 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXSessionDownloadTaskDelegate.h>
#import <ExpoFileSystem/EXTaskHandlersManager.h>
typedef void (^EXDownloadDelegateOnWriteCallback)(NSURLSessionDownloadTask *task, int64_t bytesWritten, int64_t totalBytesWritten, int64_t totalBytesExpectedToWrite);
@interface EXSessionResumableDownloadTaskDelegate : EXSessionDownloadTaskDelegate
- (nonnull instancetype)initWithResolve:(EXPromiseResolveBlock)resolve
reject:(EXPromiseRejectBlock)reject
localUrl:(NSURL *)localUrl
shouldCalculateMd5:(BOOL)shouldCalculateMd5
onWriteCallback:(EXDownloadDelegateOnWriteCallback)onWriteCallback
resumableManager:(EXTaskHandlersManager *)manager
uuid:(NSString *)uuid;
@end

View File

@@ -0,0 +1,66 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXSessionResumableDownloadTaskDelegate.h>
@interface EXSessionResumableDownloadTaskDelegate ()
@property (strong, nonatomic, readonly) EXDownloadDelegateOnWriteCallback onWriteCallback;
@property (weak, nonatomic) EXTaskHandlersManager *manager;
@property (strong, nonatomic) NSString *uuid;
@end
@implementation EXSessionResumableDownloadTaskDelegate
- (nonnull instancetype)initWithResolve:(EXPromiseResolveBlock)resolve
reject:(EXPromiseRejectBlock)reject
localUrl:(NSURL *)localUrl
shouldCalculateMd5:(BOOL)shouldCalculateMd5
onWriteCallback:(EXDownloadDelegateOnWriteCallback)onWriteCallback
resumableManager:(EXTaskHandlersManager *)manager
uuid:(NSString *)uuid;
{
if (self = [super initWithResolve:resolve
reject:reject
localUrl:localUrl
shouldCalculateMd5:shouldCalculateMd5]) {
_onWriteCallback = onWriteCallback;
_manager = manager;
_uuid = uuid;
}
return self;
}
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
{
[super URLSession:session downloadTask:downloadTask didFinishDownloadingToURL:location];
[_manager unregisterTask:_uuid];
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
if (error) {
// The task was paused by us. So, we shouldn't throw.
if (error.code == NSURLErrorCancelled) {
self.resolve([NSNull null]);
} else {
self.reject(@"ERR_FILESYSTEM_CANNOT_DOWNLOAD",
[NSString stringWithFormat:@"Unable to download file: %@", error.description],
error);
}
}
[_manager unregisterTask:_uuid];
}
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
didWriteData:(int64_t)bytesWritten
totalBytesWritten:(int64_t)totalBytesWritten
totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
{
if (_onWriteCallback && bytesWritten > 0) {
_onWriteCallback(downloadTask, bytesWritten, totalBytesWritten, totalBytesExpectedToWrite);
}
}
@end

View File

@@ -0,0 +1,32 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <Foundation/Foundation.h>
#import <ExpoModulesCore/EXDefines.h>
@interface EXSessionTaskDelegate : NSObject
@property (nonatomic, strong, readonly) EXPromiseResolveBlock resolve;
@property (nonatomic, strong, readonly) EXPromiseRejectBlock reject;
- (nonnull instancetype)initWithResolve:(EXPromiseResolveBlock)resolve
reject:(EXPromiseRejectBlock)reject;
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location;
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error;
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
didWriteData:(int64_t)bytesWritten
totalBytesWritten:(int64_t)totalBytesWritten
totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite;
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data;
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didSendBodyData:(int64_t)bytesSent
totalBytesSent:(int64_t)totalBytesSent
totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend;
- (NSDictionary *)parseServerResponse:(NSURLResponse *)response;
@end

View File

@@ -0,0 +1,58 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXSessionTaskDelegate.h>
@implementation EXSessionTaskDelegate
- (nonnull instancetype)initWithResolve:(EXPromiseResolveBlock)resolve
reject:(EXPromiseRejectBlock)reject
{
if (self = [super init]) {
_resolve = resolve;
_reject = reject;
}
return self;
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
if (error) {
self.reject(@"ERR_FILESYSTEM_CANNOT_DOWNLOAD",
[NSString stringWithFormat:@"Unable to download file: %@", error.description],
error);
}
}
- (NSDictionary *)parseServerResponse:(NSURLResponse *)response
{
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
return @{
@"status": @([httpResponse statusCode]),
@"headers": [httpResponse allHeaderFields],
@"mimeType": EXNullIfNil([httpResponse MIMEType])
};
}
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
{
}
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
didWriteData:(int64_t)bytesWritten
totalBytesWritten:(int64_t)totalBytesWritten
totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
{
}
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didSendBodyData:(int64_t)bytesSent
totalBytesSent:(int64_t)totalBytesSent
totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend
{
}
@end

View File

@@ -0,0 +1,19 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <Foundation/Foundation.h>
#import <ExpoFileSystem/EXSessionTaskDelegate.h>
#import <ExpoFileSystem/EXSessionHandler.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXSessionTaskDispatcher : NSObject <NSURLSessionDelegate>
- (instancetype)initWithSessionHandler:(nullable id<EXSessionHandler>)sessionHandler;
- (void)registerTaskDelegate:(EXSessionTaskDelegate *)delegate forTask:(NSURLSessionTask *)task;
- (void)deactivate;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,96 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXSessionTaskDispatcher.h>
#import <ExpoFileSystem/EXSessionResumableDownloadTaskDelegate.h>
@interface EXSessionTaskDispatcher ()
@property (nonatomic, strong) NSMutableDictionary<NSURLSessionTask *, EXSessionTaskDelegate *> *tasks;
@property (nonatomic) BOOL isActive;
@property (nonatomic, weak, nullable) id<EXSessionHandler> sessionHandler;
@end
@implementation EXSessionTaskDispatcher
- (instancetype)initWithSessionHandler:(nullable id<EXSessionHandler>)sessionHandler;
{
if (self = [super init]) {
_tasks = [NSMutableDictionary dictionary];
_isActive = true;
_sessionHandler = sessionHandler;
}
return self;
}
#pragma mark - public methods
- (void)registerTaskDelegate:(EXSessionTaskDelegate *)delegate forTask:(NSURLSessionTask *)task
{
_tasks[task] = delegate;
}
- (void)deactivate
{
_isActive = false;
}
#pragma mark - dispatcher
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
{
if (_isActive) {
EXSessionTaskDelegate *exTask = _tasks[downloadTask];
if (exTask) {
[exTask URLSession:session downloadTask:downloadTask didFinishDownloadingToURL:location];
[_tasks removeObjectForKey:downloadTask];
}
}
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
if (_isActive) {
EXSessionTaskDelegate *exTask = _tasks[task];
if (exTask) {
[exTask URLSession:session task:task didCompleteWithError:error];
[_tasks removeObjectForKey:task];
}
}
}
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
didWriteData:(int64_t)bytesWritten
totalBytesWritten:(int64_t)totalBytesWritten
totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
{
if (_isActive) {
EXSessionTaskDelegate *exTask = _tasks[downloadTask];
[exTask URLSession:session downloadTask:downloadTask didWriteData:bytesWritten totalBytesWritten:totalBytesWritten totalBytesExpectedToWrite:totalBytesExpectedToWrite];
}
}
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
if (_isActive) {
EXSessionTaskDelegate *exTask = _tasks[dataTask];
[exTask URLSession:session dataTask:dataTask didReceiveData:data];
}
}
- (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session {
[_sessionHandler invokeCompletionHandlerForSessionIdentifier:session.configuration.identifier];
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didSendBodyData:(int64_t)bytesSent
totalBytesSent:(int64_t)totalBytesSent
totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend
{
if (_isActive) {
EXSessionTaskDelegate *exTask = _tasks[task];
[exTask URLSession:session task:task didSendBodyData:bytesSent totalBytesSent:totalBytesSent totalBytesExpectedToSend:totalBytesExpectedToSend];
}
}
@end

View File

@@ -0,0 +1,8 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXSessionTaskDelegate.h>
@interface EXSessionUploadTaskDelegate : EXSessionTaskDelegate
@end

View File

@@ -0,0 +1,52 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXSessionUploadTaskDelegate.h>
@interface EXSessionUploadTaskDelegate ()
@property (strong, nonatomic) NSMutableData *responseData;
@end
@implementation EXSessionUploadTaskDelegate
- (instancetype)initWithResolve:(EXPromiseResolveBlock)resolve reject:(EXPromiseRejectBlock)reject
{
if (self = [super initWithResolve:resolve reject:reject]) {
_responseData = [NSMutableData new];
}
return self;
}
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
if (!data.length) {
return;
}
[_responseData appendData:data];
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
if (error) {
self.reject(@"ERR_FILESYSTEM_CANNOT_UPLOAD",
[NSString stringWithFormat:@"Unable to upload the file: '%@'", error.description],
error);
return;
}
// We only set EXSessionUploadTaskDelegates as delegates of upload tasks
// so it should be safe to assume that this is what we will receive here.
NSURLSessionUploadTask *uploadTask = (NSURLSessionUploadTask *)task;
self.resolve([self parseServerResponse:uploadTask.response]);
}
- (NSDictionary *)parseServerResponse:(NSURLResponse *)response
{
NSMutableDictionary *result = [[super parseServerResponse:response] mutableCopy];
// TODO: add support for others response types (different encodings, files)
result[@"body"] = EXNullIfNil([[NSString alloc] initWithData:_responseData encoding:NSUTF8StringEncoding]);
return result;
}
@end

View File

@@ -0,0 +1,21 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXTaskHandlersManager : NSObject
- (NSURLSessionTask * _Nullable)taskForId:(NSString *)uuid;
- (NSURLSessionDownloadTask * _Nullable)downloadTaskForId:(NSString *)uuid;
- (NSURLSessionUploadTask * _Nullable)uploadTaskForId:(NSString *)uuid;
- (void)registerTask:(NSURLSessionTask *)task uuid:(NSString *)uuid;
- (void)unregisterTask:(NSString *)uuid;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,56 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/EXTaskHandlersManager.h>
@interface EXTaskHandlersManager ()
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSURLSessionTask *> *resumableDownloads;
@end
@implementation EXTaskHandlersManager
- (instancetype)init
{
if (self = [super init]) {
_resumableDownloads = [NSMutableDictionary dictionary];
}
return self;
}
- (void)registerTask:(NSURLSessionTask *)task uuid:(NSString *)uuid
{
_resumableDownloads[uuid] = task;
}
- (NSURLSessionTask * _Nullable)taskForId:(NSString *)uuid
{
return _resumableDownloads[uuid];
}
- (NSURLSessionDownloadTask * _Nullable)downloadTaskForId:(NSString *)uuid
{
NSURLSessionTask *task = [self taskForId:uuid];
if ([task isKindOfClass:[NSURLSessionDownloadTask class]]) {
return (NSURLSessionDownloadTask *)task;
}
return nil;
}
- (NSURLSessionUploadTask * _Nullable)uploadTaskForId:(NSString *)uuid
{
NSURLSessionTask *task = [self taskForId:uuid];
if ([task isKindOfClass:[NSURLSessionUploadTask class]]) {
return (NSURLSessionDownloadTask *)task;
}
return nil;
}
- (void)unregisterTask:(NSString *)uuid
{
[_resumableDownloads removeObjectForKey:uuid];
}
@end

View File

@@ -0,0 +1,88 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
enum Encoding: String, Enumerable {
// Equivalents of String.Encoding
case ascii
case nextstep
case japaneseeuc
case utf8
case isolatin1
case symbol
case nonlossyascii
case shiftjis
case isolatin2
case unicode
case windowscp1251
case windowscp1252
case windowscp1253
case windowscp1254
case windowscp1250
case iso2022jp
case macosroman
case utf16
case utf16bigendian
case utf16littleendian
case utf32
case utf32bigendian
case utf32littleendian
// Without equivalents in String.Encoding
case base64
func toStringEncoding() -> String.Encoding? {
switch self {
case .ascii:
return .ascii
case .nextstep:
return .nextstep
case .japaneseeuc:
return .japaneseEUC
case .utf8:
return .utf8
case .isolatin1:
return .isoLatin1
case .symbol:
return .symbol
case .nonlossyascii:
return .nonLossyASCII
case .shiftjis:
return .shiftJIS
case .isolatin2:
return .isoLatin2
case .unicode:
return .unicode
case .windowscp1251:
return .windowsCP1251
case .windowscp1252:
return .windowsCP1252
case .windowscp1253:
return .windowsCP1253
case .windowscp1254:
return .windowsCP1254
case .windowscp1250:
return .windowsCP1250
case .iso2022jp:
return .iso2022JP
case .macosroman:
return .macOSRoman
case .utf16:
return .utf16
case .utf16bigendian:
return .utf16BigEndian
case .utf16littleendian:
return .utf16LittleEndian
case .utf32:
return .utf32
case .utf32bigendian:
return .utf32BigEndian
case .utf32littleendian:
return .utf32LittleEndian
// Cases that don't have their own equivalent in String.Encoding
case .base64:
return nil
}
}
}

View File

@@ -0,0 +1 @@
// Copyright 2023-present 650 Industries. All rights reserved.

View File

@@ -0,0 +1,27 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
public final class FileSystemBackgroundSessionHandler: ExpoAppDelegateSubscriber, EXSessionHandlerProtocol {
public typealias BackgroundSessionCompletionHandler = () -> Void
private var completionHandlers: [String: BackgroundSessionCompletionHandler] = [:]
public func invokeCompletionHandler(forSessionIdentifier identifier: String) {
guard let completionHandler = completionHandlers[identifier] else {
return
}
DispatchQueue.main.async {
completionHandler()
}
completionHandlers.removeValue(forKey: identifier)
}
// MARK: - ExpoAppDelegateSubscriber
#if os(iOS) || os(tvOS)
public func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
completionHandlers[identifier] = completionHandler
}
#endif
}

View File

@@ -0,0 +1,110 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
import Photos
private let assetIdentifier = "ph://"
internal func ensureFileDirectoryExists(_ fileUrl: URL) throws {
let directoryPath = fileUrl.deletingLastPathComponent()
if !FileManager.default.fileExists(atPath: directoryPath.path) {
throw DirectoryNotExistsException(directoryPath.path)
}
}
internal func readFileAsBase64(path: String, options: ReadingOptions) throws -> String {
let file = FileHandle(forReadingAtPath: path)
guard let file else {
throw FileNotExistsException(path)
}
if let position = options.position, position != 0 {
// TODO: Handle these errors?
try? file.seek(toOffset: UInt64(position))
}
if let length = options.length {
return file.readData(ofLength: length).base64EncodedString(options: .endLineWithLineFeed)
}
return file.readDataToEndOfFile().base64EncodedString(options: .endLineWithLineFeed)
}
internal func writeFileAsBase64(path: String, string: String) throws {
let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters)
if !FileManager.default.createFile(atPath: path, contents: data) {
throw FileWriteFailedException(path)
}
}
internal func removeFile(path: String, idempotent: Bool = false) throws {
if FileManager.default.fileExists(atPath: path) {
do {
try FileManager.default.removeItem(atPath: path)
} catch {
throw FileCannotDeleteException(path)
.causedBy(error)
}
} else if !idempotent {
throw FileCannotDeleteException(path)
.causedBy(FileNotExistsException(path))
}
}
internal func getResourceValues(from directory: URL?, forKeys: Set<URLResourceKey>) throws -> URLResourceValues? {
do {
return try directory?.resourceValues(forKeys: forKeys)
} catch {
throw CannotDetermineDiskCapacity().causedBy(error)
}
}
internal func ensurePathPermission(_ appContext: AppContext?, path: String, flag: EXFileSystemPermissionFlags) throws {
guard let fileSystemManager = appContext?.fileSystem else {
throw Exceptions.PermissionsModuleNotFound()
}
guard fileSystemManager.getPathPermissions(path).contains(flag) else {
throw flag == .read ? FileNotReadableException(path) : FileNotWritableException(path)
}
}
internal func isPHAsset(path: String) -> Bool {
return path.contains(assetIdentifier)
}
internal func copyPHAsset(fromUrl: URL, toUrl: URL, with resourceManager: PHAssetResourceManager, promise: Promise) {
if isPhotoLibraryStatusAuthorized() {
if FileManager.default.fileExists(atPath: toUrl.path) {
promise.reject(FileAlreadyExistsException(toUrl.path))
return
}
let identifier = fromUrl.absoluteString.replacingOccurrences(of: assetIdentifier, with: "")
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else {
promise.reject(FailedToFindAssetException(fromUrl.absoluteString))
return
}
let firstResource = PHAssetResource.assetResources(for: asset).first
if let firstResource {
resourceManager.writeData(for: firstResource, toFile: toUrl, options: nil) { error in
if error != nil {
promise.reject(FailedToCopyAssetException(fromUrl.absoluteString))
return
}
promise.resolve()
}
} else {
promise.reject(FailedToCopyAssetException(fromUrl.absoluteString))
}
}
}
internal func isPhotoLibraryStatusAuthorized() -> Bool {
if #available(iOS 14, tvOS 14, *) {
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
return status == .authorized || status == .limited
}
return PHPhotoLibrary.authorizationStatus() == .authorized
}

View File

@@ -0,0 +1,99 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
final class FileNotExistsException: GenericException<String> {
override var reason: String {
"File '\(param)' does not exist"
}
}
final class FileAlreadyExistsException: GenericException<String> {
override var reason: String {
"File '\(param)' already exists"
}
}
final class DirectoryNotExistsException: GenericException<String> {
override var reason: String {
"Directory '\(param)' does not exist"
}
}
final class FileNotReadableException: GenericException<String> {
override var reason: String {
"File '\(param)' is not readable"
}
}
final class FileNotWritableException: GenericException<String> {
override var reason: String {
"File '\(param)' is not writable"
}
}
final class FileWriteFailedException: GenericException<String> {
override var reason: String {
"Writing to '\(param)' file has failed"
}
}
final class FileCannotDeleteException: GenericException<String> {
override var reason: String {
"File '\(param)' could not be deleted"
}
}
final class InvalidFileUrlException: GenericException<URL> {
override var reason: String {
"'\(param.absoluteString)' is not a file URL"
}
}
final class UnsupportedSchemeException: GenericException<String?> {
override var reason: String {
"Unsupported URI scheme: '\(String(describing: param))'"
}
}
final class HeaderEncodingFailedException: GenericException<String> {
override var reason: String {
"Unable to encode headers for request '\(param)' to UTF8"
}
}
final class DownloadTaskNotFoundException: GenericException<String> {
override var reason: String {
"Cannot find a download task with id: '\(param)'"
}
}
final class CannotDetermineDiskCapacity: Exception {
override var reason: String {
"Unable to determine free disk storage capacity"
}
}
final class FailedToCreateBodyException: Exception {
override var reason: String {
"Unable to create multipart body"
}
}
final class FailedToAccessDirectoryException: Exception {
override var reason: String {
"Failed to access `Caches` directory"
}
}
final class FailedToCopyAssetException: GenericException<String> {
override var reason: String {
"Failed to copy photo library asset: \(param)"
}
}
final class FailedToFindAssetException: GenericException<String> {
override var reason: String {
"Failed to find photo library asset: \(param)"
}
}

View File

@@ -0,0 +1,316 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
import Photos
private let EVENT_DOWNLOAD_PROGRESS = "expo-file-system.downloadProgress"
private let EVENT_UPLOAD_PROGRESS = "expo-file-system.uploadProgress"
public final class FileSystemLegacyModule: Module {
private var sessionTaskDispatcher: EXSessionTaskDispatcher!
private lazy var taskHandlersManager = EXTaskHandlersManager()
private lazy var resourceManager = PHAssetResourceManager()
private lazy var backgroundSession = createUrlSession(type: .background, delegate: sessionTaskDispatcher)
private lazy var foregroundSession = createUrlSession(type: .foreground, delegate: sessionTaskDispatcher)
private var documentDirectory: URL? {
return appContext?.config.documentDirectory
}
private var cacheDirectory: URL? {
return appContext?.config.cacheDirectory
}
public func definition() -> ModuleDefinition {
Name("ExponentFileSystem")
Constant("documentDirectory") {
return documentDirectory?.absoluteString
}
Constant("cacheDirectory") {
return cacheDirectory?.absoluteString
}
Constant("bundleDirectory") {
return Bundle.main.bundlePath
}
Events(EVENT_DOWNLOAD_PROGRESS, EVENT_UPLOAD_PROGRESS)
OnCreate {
Task { @MainActor in
sessionTaskDispatcher = EXSessionTaskDispatcher(
sessionHandler: ExpoAppDelegateSubscriberRepository.getSubscriberOfType(FileSystemBackgroundSessionHandler.self)
)
}
}
AsyncFunction("getInfoAsync") { (url: URL, options: InfoOptions, promise: Promise) in
let optionsDict = options.toDictionary(appContext: appContext)
switch url.scheme {
case "file":
EXFileSystemLocalFileHandler.getInfoForFile(url, withOptions: optionsDict, resolver: promise.resolver, rejecter: promise.legacyRejecter)
case "assets-library", "ph":
EXFileSystemAssetLibraryHandler.getInfoForFile(url, withOptions: optionsDict, resolver: promise.resolver, rejecter: promise.legacyRejecter)
default:
throw UnsupportedSchemeException(url.scheme)
}
}
AsyncFunction("readAsStringAsync") { (url: URL, options: ReadingOptions) -> String in
try ensurePathPermission(appContext, path: url.path, flag: .read)
if options.encoding == .base64 {
return try readFileAsBase64(path: url.path, options: options)
}
do {
return try String(contentsOfFile: url.path, encoding: options.encoding.toStringEncoding() ?? .utf8)
} catch {
throw FileNotReadableException(url.path)
}
}
AsyncFunction("writeAsStringAsync") { (url: URL, string: String, options: WritingOptions) in
try ensurePathPermission(appContext, path: url.path, flag: .write)
let data: Data?
if options.encoding == .base64 {
data = Data(base64Encoded: string, options: .ignoreUnknownCharacters)
} else {
data = string.data(using: options.encoding.toStringEncoding() ?? .utf8)
}
guard let data else {
throw FileNotWritableException(url.path)
}
do {
if options.append {
if !FileManager.default.fileExists(atPath: url.path) {
try data.write(to: url, options: .atomic)
} else {
let fileHandle = try FileHandle(forWritingTo: url)
defer {
fileHandle.closeFile()
}
fileHandle.seekToEndOfFile()
fileHandle.write(data)
}
} else {
try data.write(to: url, options: .atomic)
}
} catch {
throw FileNotWritableException(url.path)
.causedBy(error)
}
}
AsyncFunction("deleteAsync") { (url: URL, options: DeletingOptions) in
guard url.isFileURL else {
throw InvalidFileUrlException(url)
}
try ensurePathPermission(appContext, path: url.appendingPathComponent("..").path, flag: .write)
try removeFile(path: url.path, idempotent: options.idempotent)
}
AsyncFunction("moveAsync") { (options: RelocatingOptions) in
let (fromUrl, toUrl) = try options.asTuple()
guard fromUrl.isFileURL else {
throw InvalidFileUrlException(fromUrl)
}
guard toUrl.isFileURL else {
throw InvalidFileUrlException(toUrl)
}
try ensurePathPermission(appContext, path: fromUrl.appendingPathComponent("..").path, flag: .write)
try ensurePathPermission(appContext, path: toUrl.path, flag: .write)
try removeFile(path: toUrl.path, idempotent: true)
try FileManager.default.moveItem(atPath: fromUrl.path, toPath: toUrl.path)
}
AsyncFunction("copyAsync") { (options: RelocatingOptions, promise: Promise) in
let (fromUrl, toUrl) = try options.asTuple()
if isPHAsset(path: fromUrl.absoluteString) {
copyPHAsset(fromUrl: fromUrl, toUrl: toUrl, with: resourceManager, promise: promise)
return
}
try ensurePathPermission(appContext, path: fromUrl.path, flag: .read)
try ensurePathPermission(appContext, path: toUrl.path, flag: .write)
if fromUrl.scheme == "file" {
EXFileSystemLocalFileHandler.copy(from: fromUrl, to: toUrl, resolver: promise.resolver, rejecter: promise.legacyRejecter)
} else if ["ph", "assets-library"].contains(fromUrl.scheme) {
EXFileSystemAssetLibraryHandler.copy(from: fromUrl, to: toUrl, resolver: promise.resolver, rejecter: promise.legacyRejecter)
} else {
throw InvalidFileUrlException(fromUrl)
}
}
AsyncFunction("makeDirectoryAsync") { (url: URL, options: MakeDirectoryOptions) in
guard url.isFileURL else {
throw InvalidFileUrlException(url)
}
try ensurePathPermission(appContext, path: url.path, flag: .write)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: options.intermediates, attributes: nil)
}
AsyncFunction("readDirectoryAsync") { (url: URL) -> [String] in
guard url.isFileURL else {
throw InvalidFileUrlException(url)
}
try ensurePathPermission(appContext, path: url.path, flag: .read)
return try FileManager.default.contentsOfDirectory(atPath: url.path)
}
AsyncFunction("downloadAsync") { (sourceUrl: URL, localUrl: URL, options: DownloadOptionsLegacy, promise: Promise) in
try ensureFileDirectoryExists(localUrl)
try ensurePathPermission(appContext, path: localUrl.path, flag: .write)
if sourceUrl.isFileURL {
try ensurePathPermission(appContext, path: sourceUrl.path, flag: .read)
EXFileSystemLocalFileHandler.copy(from: sourceUrl, to: localUrl, resolver: promise.resolver, rejecter: promise.legacyRejecter)
return
}
let session = options.sessionType == .background ? backgroundSession : foregroundSession
let request = createUrlRequest(url: sourceUrl, headers: options.headers)
let downloadTask = session.downloadTask(with: request)
let taskDelegate = EXSessionDownloadTaskDelegate(
resolve: promise.resolver,
reject: promise.legacyRejecter,
localUrl: localUrl,
shouldCalculateMd5: options.md5
)
sessionTaskDispatcher.register(taskDelegate, for: downloadTask)
downloadTask.resume()
}
AsyncFunction("uploadAsync") { (targetUrl: URL, localUrl: URL, options: UploadOptions, promise: Promise) in
guard localUrl.isFileURL else {
throw InvalidFileUrlException(localUrl)
}
guard FileManager.default.fileExists(atPath: localUrl.path) else {
throw FileNotExistsException(localUrl.path)
}
let session = options.sessionType == .background ? backgroundSession : foregroundSession
let task = try createUploadTask(session: session, targetUrl: targetUrl, sourceUrl: localUrl, options: options)
let taskDelegate = EXSessionUploadTaskDelegate(resolve: promise.resolver, reject: promise.legacyRejecter)
sessionTaskDispatcher.register(taskDelegate, for: task)
task.resume()
}
AsyncFunction("uploadTaskStartAsync") { (targetUrl: URL, localUrl: URL, uuid: String, options: UploadOptions, promise: Promise) in
let session = options.sessionType == .background ? backgroundSession : foregroundSession
let task = try createUploadTask(session: session, targetUrl: targetUrl, sourceUrl: localUrl, options: options)
let onSend: EXUploadDelegateOnSendCallback = { [weak self] _, _, totalBytesSent, totalBytesExpectedToSend in
self?.sendEvent(EVENT_UPLOAD_PROGRESS, [
"uuid": uuid,
"data": [
"totalBytesSent": totalBytesSent,
"totalBytesExpectedToSend": totalBytesExpectedToSend
]
])
}
let taskDelegate = EXSessionCancelableUploadTaskDelegate(
resolve: promise.resolver,
reject: promise.legacyRejecter,
onSendCallback: onSend,
resumableManager: taskHandlersManager,
uuid: uuid
)
sessionTaskDispatcher.register(taskDelegate, for: task)
taskHandlersManager.register(task, uuid: uuid)
task.resume()
}
// swiftlint:disable:next line_length closure_body_length
AsyncFunction("downloadResumableStartAsync") { (sourceUrl: URL, localUrl: URL, uuid: String, options: DownloadOptionsLegacy, resumeDataString: String?, promise: Promise) in
try ensureFileDirectoryExists(localUrl)
try ensurePathPermission(appContext, path: localUrl.path, flag: .write)
let session = options.sessionType == .background ? backgroundSession : foregroundSession
let onWrite: EXDownloadDelegateOnWriteCallback = { [weak self] _, _, totalBytesWritten, totalBytesExpectedToWrite in
self?.sendEvent(EVENT_DOWNLOAD_PROGRESS, [
"uuid": uuid,
"data": [
"totalBytesWritten": totalBytesWritten,
"totalBytesExpectedToWrite": totalBytesExpectedToWrite
]
])
}
let task: URLSessionDownloadTask
if let resumeDataString, let resumeData = Data(base64Encoded: resumeDataString) {
task = session.downloadTask(withResumeData: resumeData)
} else {
let request = createUrlRequest(url: sourceUrl, headers: options.headers)
task = session.downloadTask(with: request)
}
let taskDelegate = EXSessionResumableDownloadTaskDelegate(
resolve: promise.resolver,
reject: promise.legacyRejecter,
localUrl: localUrl,
shouldCalculateMd5: options.md5,
onWriteCallback: onWrite,
resumableManager: taskHandlersManager,
uuid: uuid
)
sessionTaskDispatcher.register(taskDelegate, for: task)
taskHandlersManager.register(task, uuid: uuid)
task.resume()
}
AsyncFunction("downloadResumablePauseAsync") { (id: String) -> [String: String?] in
guard let task = taskHandlersManager.downloadTask(forId: id) else {
throw DownloadTaskNotFoundException(id)
}
let resumeData = await task.cancelByProducingResumeData()
return [
"resumeData": resumeData?.base64EncodedString()
]
}
AsyncFunction("networkTaskCancelAsync") { (id: String) in
taskHandlersManager.task(forId: id)?.cancel()
}
AsyncFunction("getFreeDiskStorageAsync") { () -> Int64 in
// Uses required reason API based on the following reason: E174.1 85F4.1
#if !os(tvOS)
let resourceValues = try getResourceValues(from: documentDirectory, forKeys: [.volumeAvailableCapacityForImportantUsageKey])
guard let availableCapacity = resourceValues?.volumeAvailableCapacityForImportantUsage else {
throw CannotDetermineDiskCapacity()
}
return availableCapacity
#else
let resourceValues = try getResourceValues(from: cacheDirectory, forKeys: [.volumeAvailableCapacityKey])
guard let availableCapacity = resourceValues?.volumeAvailableCapacity else {
throw CannotDetermineDiskCapacity()
}
return Int64(availableCapacity)
#endif
}
AsyncFunction("getTotalDiskCapacityAsync") { () -> Int in
// Uses required reason API based on the following reason: E174.1 85F4.1
let resourceValues = try getResourceValues(from: documentDirectory, forKeys: [.volumeTotalCapacityKey])
guard let totalCapacity = resourceValues?.volumeTotalCapacity else {
throw CannotDetermineDiskCapacity()
}
return totalCapacity
}
}
}

View File

@@ -0,0 +1,74 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
struct InfoOptions: Record {
@Field var md5: Bool = false
}
struct ReadingOptions: Record {
@Field var encoding: Encoding = .utf8
@Field var position: Int?
@Field var length: Int?
}
struct WritingOptions: Record {
@Field var encoding: Encoding = .utf8
@Field var append: Bool = false
}
struct DeletingOptions: Record {
@Field var idempotent: Bool = false
}
struct RelocatingOptions: Record {
@Field var from: URL?
@Field var to: URL?
func asTuple() throws -> (URL, URL) {
guard let from, let to else {
let missingOptionName = from == nil ? "from" : "to"
throw Exception(name: "MissingParameterException", description: "Missing option '\(missingOptionName)'")
}
return (from, to)
}
}
struct MakeDirectoryOptions: Record {
@Field var intermediates: Bool = false
}
struct DownloadOptionsLegacy: Record {
@Field var md5: Bool = false
@Field var cache: Bool = false
@Field var headers: [String: String]?
@Field var sessionType: SessionType = .background
}
struct UploadOptions: Record {
@Field var headers: [String: String]?
@Field var httpMethod: HttpMethod = .post
@Field var sessionType: SessionType = .background
@Field var uploadType: UploadType = .binaryContent
// Multipart
@Field var fieldName: String?
@Field var mimeType: String?
@Field var parameters: [String: String]?
}
enum SessionType: Int, Enumerable {
case background = 0
case foreground = 1
}
enum HttpMethod: String, Enumerable {
case post = "POST"
case put = "PUT"
case patch = "PATCH"
}
enum UploadType: Int, Enumerable {
case binaryContent = 0
case multipart = 1
}

View File

@@ -0,0 +1,9 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <Foundation/Foundation.h>
@interface NSData (EXFileSystem)
- (NSString *)md5String;
@end

View File

@@ -0,0 +1,19 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <ExpoFileSystem/NSData+EXFileSystem.h>
#import <CommonCrypto/CommonDigest.h>
@implementation NSData (EXFileSystem)
- (NSString *)md5String
{
unsigned char digest[CC_MD5_DIGEST_LENGTH];
CC_MD5(self.bytes, (CC_LONG) self.length, digest);
NSMutableString *md5 = [NSMutableString stringWithCapacity:2 * CC_MD5_DIGEST_LENGTH];
for (unsigned int i = 0; i < CC_MD5_DIGEST_LENGTH; ++i) {
[md5 appendFormat:@"%02x", digest[i]];
}
return md5;
}
@end

View File

@@ -0,0 +1,98 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import CoreServices
import ExpoModulesCore
func findMimeType(forAttachment attachment: URL) -> String {
if let identifier = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, attachment.pathExtension as CFString, nil)?.takeRetainedValue() {
if let type = UTTypeCopyPreferredTagWithClass(identifier, kUTTagClassMIMEType)?.takeRetainedValue() {
return type as String
}
}
return "application/octet-stream"
}
func createUrlSession(type: SessionType, delegate: URLSessionDelegate) -> URLSession {
let configuration = type == .foreground ? URLSessionConfiguration.default : URLSessionConfiguration.background(withIdentifier: UUID().uuidString)
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
return URLSession(configuration: configuration, delegate: delegate, delegateQueue: .main)
}
func createUrlRequest(url: URL, headers: [String: String]?) -> URLRequest {
var request = URLRequest(url: url)
if let headers {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
}
return request
}
func createUploadTask(session: URLSession, targetUrl: URL, sourceUrl: URL, options: UploadOptions) throws -> URLSessionUploadTask {
var request = createUrlRequest(url: targetUrl, headers: options.headers)
request.httpMethod = options.httpMethod.rawValue
switch options.uploadType {
case .binaryContent:
return session.uploadTask(with: request, fromFile: sourceUrl)
case .multipart:
let boundaryString = UUID().uuidString
guard let data = createMultipartBody(boundary: boundaryString, sourceUrl: sourceUrl, options: options) else {
throw FailedToCreateBodyException()
}
request.setValue("multipart/form-data; boundary=\(boundaryString)", forHTTPHeaderField: "Content-Type")
let localURL = try createLocalUrl(from: sourceUrl)
try? data.write(to: localURL)
return session.uploadTask(with: request, fromFile: localURL)
}
}
func createLocalUrl(from sourceUrl: URL) throws -> URL {
guard let cachesDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
throw FailedToAccessDirectoryException()
}
let tempDir = cachesDir.appendingPathComponent("uploads")
FileSystemUtilities.ensureDirExists(at: tempDir)
return tempDir.appendingPathComponent(sourceUrl.lastPathComponent)
}
func createMultipartBody(boundary: String, sourceUrl: URL, options: UploadOptions) -> Data? {
let fieldName = options.fieldName ?? sourceUrl.lastPathComponent
let mimeType = options.mimeType ?? findMimeType(forAttachment: sourceUrl)
guard let data = try? Data(contentsOf: sourceUrl) else {
return nil
}
var body = Data()
headersForMultipartParams(options.parameters, boundary: boundary, body: &body)
body.append("--\(boundary)\r\n".data)
body.append("Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(sourceUrl.lastPathComponent)\"\r\n".data)
body.append("Content-Type: \(mimeType)\r\n\r\n".data)
body.append(data)
body.append("\r\n".data)
body.append("--\(boundary)--\r\n".data)
return body
}
func headersForMultipartParams(_ params: [String: String]?, boundary: String, body: inout Data) {
if let params {
for (key, value) in params {
body.append("--\(boundary)\r\n".data)
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data)
body.append("\(value)\r\n".data)
}
}
}
// All swift strings are unicode correct.
// This avoids the optional created by string.data(using: .utf8)
private extension String {
var data: Data { Data(self.utf8) }
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyCollectedDataTypes</key>
<array>
</array>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>0A2A.1</string>
<string>3B52.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
<string>85F4.1</string>
</array>
</dict>
</array>
</dict>
</plist>