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,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) }
}