expo-file-system 16.0.5 β†’ 16.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,19 @@
10
10
 
11
11
  ### πŸ’‘ Others
12
12
 
13
+ ## 16.0.7 β€” 2024-02-27
14
+
15
+ ### πŸ› Bug fixes
16
+
17
+ - [iOS] Fix downloadAsync for local files. ([#27187](https://github.com/expo/expo/pull/27187) by [@gabrieldonadel](https://github.com/gabrieldonadel))
18
+ - On `iOS`, fix an issue with `copyAsync` where the copy fails if it is a photo library asset. ([#27208](https://github.com/expo/expo/pull/27208) by [@alanjhughes](https://github.com/alanjhughes))
19
+
20
+ ## 16.0.6 β€” 2024-02-06
21
+
22
+ ### πŸ› Bug fixes
23
+
24
+ - On `iOS`, fix upload task requests. ([#26880](https://github.com/expo/expo/pull/26880) by [@alanjhughes](https://github.com/alanjhughes))
25
+
13
26
  ## 16.0.5 β€” 2024-01-23
14
27
 
15
28
  ### πŸ› Bug fixes
@@ -3,7 +3,7 @@ apply plugin: 'kotlin-android'
3
3
  apply plugin: 'maven-publish'
4
4
 
5
5
  group = 'host.exp.exponent'
6
- version = '16.0.5'
6
+ version = '16.0.7'
7
7
 
8
8
  def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
9
9
  if (expoModulesCorePlugin.exists()) {
@@ -94,7 +94,7 @@ android {
94
94
  namespace "expo.modules.filesystem"
95
95
  defaultConfig {
96
96
  versionCode 30
97
- versionName "16.0.5"
97
+ versionName "16.0.7"
98
98
  }
99
99
  }
100
100
 
@@ -8,6 +8,12 @@ final class FileNotExistsException: GenericException<String> {
8
8
  }
9
9
  }
10
10
 
11
+ final class FileAlreadyExistsException: GenericException<String> {
12
+ override var reason: String {
13
+ "File '\(param)' already exists"
14
+ }
15
+ }
16
+
11
17
  final class DirectoryNotExistsException: GenericException<String> {
12
18
  override var reason: String {
13
19
  "Directory '\(param)' does not exist"
@@ -67,3 +73,27 @@ final class CannotDetermineDiskCapacity: Exception {
67
73
  "Unable to determine free disk storage capacity"
68
74
  }
69
75
  }
76
+
77
+ final class FailedToCreateBodyException: Exception {
78
+ override var reason: String {
79
+ "Unable to create multipart body"
80
+ }
81
+ }
82
+
83
+ final class FailedToAccessDirectoryException: Exception {
84
+ override var reason: String {
85
+ "Failed to access `Caches` directory"
86
+ }
87
+ }
88
+
89
+ final class FailedToCopyAssetException: GenericException<String> {
90
+ override var reason: String {
91
+ "Failed to copy photo library asset: \(param)"
92
+ }
93
+ }
94
+
95
+ final class FailedToFindAssetException: GenericException<String> {
96
+ override var reason: String {
97
+ "Failed to find photo library asset: \(param)"
98
+ }
99
+ }
@@ -1,6 +1,9 @@
1
1
  // Copyright 2023-present 650 Industries. All rights reserved.
2
2
 
3
3
  import ExpoModulesCore
4
+ import Photos
5
+
6
+ private let assetIdentifier = "ph://"
4
7
 
5
8
  internal func ensureFileDirectoryExists(_ fileUrl: URL) throws {
6
9
  let directoryPath = fileUrl.deletingLastPathComponent()
@@ -64,3 +67,42 @@ internal func ensurePathPermission(_ appContext: AppContext?, path: String, flag
64
67
  throw flag == .read ? FileNotReadableException(path) : FileNotWritableException(path)
65
68
  }
66
69
  }
70
+
71
+ internal func isPHAsset(path: String) -> Bool {
72
+ return path.contains(assetIdentifier)
73
+ }
74
+
75
+ internal func copyPHAsset(fromUrl: URL, toUrl: URL, with resourceManager: PHAssetResourceManager, promise: Promise) {
76
+ if isPhotoLibraryStatusAuthorized() {
77
+ if FileManager.default.fileExists(atPath: toUrl.path) {
78
+ promise.reject(FileAlreadyExistsException(toUrl.path))
79
+ return
80
+ }
81
+
82
+ let identifier = fromUrl.absoluteString.replacingOccurrences(of: assetIdentifier, with: "")
83
+
84
+ guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else {
85
+ promise.reject(FailedToFindAssetException(fromUrl.absoluteString))
86
+ return
87
+ }
88
+
89
+ let firstResource = PHAssetResource.assetResources(for: asset).first
90
+ if let firstResource {
91
+ resourceManager.writeData(for: firstResource, toFile: toUrl, options: nil) { error in
92
+ if let error {
93
+ promise.reject(FailedToCopyAssetException(fromUrl.absoluteString))
94
+ }
95
+ }
96
+ } else {
97
+ promise.reject(FailedToCopyAssetException(fromUrl.absoluteString))
98
+ }
99
+ }
100
+ }
101
+
102
+ internal func isPhotoLibraryStatusAuthorized() -> Bool {
103
+ if #available(iOS 14, tvOS 14, *) {
104
+ let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
105
+ return status == .authorized || status == .limited
106
+ }
107
+ return PHPhotoLibrary.authorizationStatus() == .authorized
108
+ }
@@ -1,6 +1,7 @@
1
1
  // Copyright 2023-present 650 Industries. All rights reserved.
2
2
 
3
3
  import ExpoModulesCore
4
+ import Photos
4
5
 
5
6
  private let EVENT_DOWNLOAD_PROGRESS = "expo-file-system.downloadProgress"
6
7
  private let EVENT_UPLOAD_PROGRESS = "expo-file-system.uploadProgress"
@@ -8,6 +9,7 @@ private let EVENT_UPLOAD_PROGRESS = "expo-file-system.uploadProgress"
8
9
  public final class FileSystemModule: Module {
9
10
  private lazy var sessionTaskDispatcher = EXSessionTaskDispatcher(sessionHandler: ExpoAppDelegate.getSubscriberOfType(FileSystemBackgroundSessionHandler.self))
10
11
  private lazy var taskHandlersManager = EXTaskHandlersManager()
12
+ private lazy var resourceManager = PHAssetResourceManager()
11
13
 
12
14
  private lazy var backgroundSession = createUrlSession(type: .background, delegate: sessionTaskDispatcher)
13
15
  private lazy var foregroundSession = createUrlSession(type: .foreground, delegate: sessionTaskDispatcher)
@@ -100,6 +102,11 @@ public final class FileSystemModule: Module {
100
102
  AsyncFunction("copyAsync") { (options: RelocatingOptions, promise: Promise) in
101
103
  let (fromUrl, toUrl) = try options.asTuple()
102
104
 
105
+ if isPHAsset(path: fromUrl.absoluteString) {
106
+ copyPHAsset(fromUrl: fromUrl, toUrl: toUrl, with: resourceManager, promise: promise)
107
+ return
108
+ }
109
+
103
110
  try ensurePathPermission(appContext, path: fromUrl.path, flag: .read)
104
111
  try ensurePathPermission(appContext, path: toUrl.path, flag: .write)
105
112
 
@@ -134,6 +141,11 @@ public final class FileSystemModule: Module {
134
141
  try ensureFileDirectoryExists(localUrl)
135
142
  try ensurePathPermission(appContext, path: localUrl.path, flag: .write)
136
143
 
144
+ if sourceUrl.isFileURL {
145
+ try ensurePathPermission(appContext, path: sourceUrl.path, flag: .read)
146
+ EXFileSystemLocalFileHandler.copy(from: sourceUrl, to: localUrl, resolver: promise.resolver, rejecter: promise.legacyRejecter)
147
+ return
148
+ }
137
149
  let session = options.sessionType == .background ? backgroundSession : foregroundSession
138
150
  let request = createUrlRequest(url: sourceUrl, headers: options.headers)
139
151
  let downloadTask = session.downloadTask(with: request)
@@ -156,7 +168,7 @@ public final class FileSystemModule: Module {
156
168
  throw FileNotExistsException(localUrl.path)
157
169
  }
158
170
  let session = options.sessionType == .background ? backgroundSession : foregroundSession
159
- let task = createUploadTask(session: session, targetUrl: targetUrl, sourceUrl: localUrl, options: options)
171
+ let task = try createUploadTask(session: session, targetUrl: targetUrl, sourceUrl: localUrl, options: options)
160
172
  let taskDelegate = EXSessionUploadTaskDelegate(resolve: promise.resolver, reject: promise.legacyRejecter)
161
173
 
162
174
  sessionTaskDispatcher.register(taskDelegate, for: task)
@@ -165,7 +177,7 @@ public final class FileSystemModule: Module {
165
177
 
166
178
  AsyncFunction("uploadTaskStartAsync") { (targetUrl: URL, localUrl: URL, uuid: String, options: UploadOptions, promise: Promise) in
167
179
  let session = options.sessionType == .background ? backgroundSession : foregroundSession
168
- let task = createUploadTask(session: session, targetUrl: targetUrl, sourceUrl: localUrl, options: options)
180
+ let task = try createUploadTask(session: session, targetUrl: targetUrl, sourceUrl: localUrl, options: options)
169
181
  let onSend: EXUploadDelegateOnSendCallback = { [weak self] _, _, totalBytesSent, totalBytesExpectedToSend in
170
182
  self?.sendEvent(EVENT_UPLOAD_PROGRESS, [
171
183
  "uuid": uuid,
@@ -30,7 +30,7 @@ func createUrlRequest(url: URL, headers: [String: String]?) -> URLRequest {
30
30
  return request
31
31
  }
32
32
 
33
- func createUploadTask(session: URLSession, targetUrl: URL, sourceUrl: URL, options: UploadOptions) -> URLSessionUploadTask {
33
+ func createUploadTask(session: URLSession, targetUrl: URL, sourceUrl: URL, options: UploadOptions) throws -> URLSessionUploadTask {
34
34
  var request = createUrlRequest(url: targetUrl, headers: options.headers)
35
35
  request.httpMethod = options.httpMethod.rawValue
36
36
 
@@ -39,47 +39,60 @@ func createUploadTask(session: URLSession, targetUrl: URL, sourceUrl: URL, optio
39
39
  return session.uploadTask(with: request, fromFile: sourceUrl)
40
40
  case .multipart:
41
41
  let boundaryString = UUID().uuidString
42
- let data = try? createMultipartBody(boundary: boundaryString, sourceUrl: sourceUrl, options: options)
42
+ guard let data = createMultipartBody(boundary: boundaryString, sourceUrl: sourceUrl, options: options) else {
43
+ throw FailedToCreateBodyException()
44
+ }
43
45
 
44
46
  request.setValue("multipart/form-data; boundary=\(boundaryString)", forHTTPHeaderField: "Content-Type")
45
- request.httpBody = data
46
47
 
47
- return session.uploadTask(withStreamedRequest: request)
48
+ let localURL = try createLocalUrl(from: sourceUrl)
49
+ try? data.write(to: localURL)
50
+
51
+ return session.uploadTask(with: request, fromFile: localURL)
52
+ }
53
+ }
54
+
55
+ func createLocalUrl(from sourceUrl: URL) throws -> URL {
56
+ guard let cachesDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
57
+ throw FailedToAccessDirectoryException()
48
58
  }
59
+ let tempDir = cachesDir.appendingPathComponent("uploads")
60
+ FileSystemUtilities.ensureDirExists(at: tempDir)
61
+ return tempDir.appendingPathComponent(sourceUrl.lastPathComponent)
49
62
  }
50
63
 
51
- func createMultipartBody(boundary: String, sourceUrl: URL, options: UploadOptions) throws -> Data {
52
- let fileName = options.fieldName ?? sourceUrl.lastPathComponent
53
- let fileContents = try String(contentsOf: sourceUrl)
54
- let mimeType = options.mimeType ?? findMimeType(forAttachment: sourceUrl)
64
+ func createMultipartBody(boundary: String, sourceUrl: URL, options: UploadOptions) -> Data? {
65
+ let fieldName = options.fieldName ?? sourceUrl.lastPathComponent
66
+ var mimeType = options.mimeType ?? findMimeType(forAttachment: sourceUrl)
67
+ guard let data = try? Data(contentsOf: sourceUrl) else {
68
+ return nil
69
+ }
55
70
 
56
- let body = """
57
- \(headersForMultipartParams(options.parameters, boundary: boundary))
58
- --\(boundary)
59
- Content-Disposition: form-data; name="\(fileName)"; filename="\(sourceUrl.lastPathComponent)"
60
- Content-Type: \(mimeType)
71
+ var body = Data()
72
+ headersForMultipartParams(options.parameters, boundary: boundary, body: &body)
61
73
 
62
- \(fileContents)
63
- --\(boundary)--
64
- """
74
+ body.append("--\(boundary)\r\n".data)
75
+ body.append("Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(sourceUrl.lastPathComponent)\"\r\n".data)
76
+ body.append("Content-Type: \(mimeType)\r\n\r\n".data)
77
+ body.append(data)
78
+ body.append("\r\n".data)
79
+ body.append("--\(boundary)--\r\n".data)
65
80
 
66
- guard let bodyData = body.data(using: .utf8) else {
67
- throw HeaderEncodingFailedException(sourceUrl.absoluteString)
68
- }
69
- return bodyData
81
+ return body
70
82
  }
71
83
 
72
- func headersForMultipartParams(_ params: [String: String]?, boundary: String) -> String {
73
- guard let params else {
74
- return ""
84
+ func headersForMultipartParams(_ params: [String: String]?, boundary: String, body: inout Data) {
85
+ if let params {
86
+ for (key, value) in params {
87
+ body.append("--\(boundary)\r\n".data)
88
+ body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data)
89
+ body.append("\(value)\r\n".data)
90
+ }
75
91
  }
76
- return params.map { (key: String, value: String) in
77
- """
78
- --\(boundary)
79
- Content-Disposition: form-data; name="\(key)"
92
+ }
80
93
 
81
- \(value)
82
- """
83
- }
84
- .joined()
94
+ // All swift strings are unicode correct.
95
+ // This avoids the optional created by string.data(using: .utf8)
96
+ private extension String {
97
+ var data: Data { Data(self.utf8) }
85
98
  }
@@ -20,7 +20,7 @@ class EXFileSystemSpec: ExpoSpec {
20
20
  it("should handle UTF-8 characters") {
21
21
  let utf8UriInput = "file:///var/mobile/δΈ­ζ–‡"
22
22
  let utf8UriExpectedOutput = "file:///var/mobile/%E4%B8%AD%E6%96%87"
23
- let utf8Uri = fileSystem.percentEncodedURL(fromURIString:utf8UriInput)
23
+ let utf8Uri = fileSystem.percentEncodedURL(fromURIString: utf8UriInput)
24
24
 
25
25
  expect(utf8Uri?.absoluteString) == utf8UriExpectedOutput
26
26
  expect(utf8Uri?.scheme) == "file"
@@ -29,7 +29,7 @@ class EXFileSystemSpec: ExpoSpec {
29
29
  it("should handle URI with percent, numbers and UTF-8 characters") {
30
30
  let input = "file:///document/directory/%40%2FδΈ­ζ–‡"
31
31
  let expectedOutput = "file:///document/directory/%40%2F%E4%B8%AD%E6%96%87"
32
- let uri = fileSystem.percentEncodedURL(fromURIString:input)
32
+ let uri = fileSystem.percentEncodedURL(fromURIString: input)
33
33
 
34
34
  expect(uri?.absoluteString) == expectedOutput
35
35
  }
@@ -37,7 +37,7 @@ class EXFileSystemSpec: ExpoSpec {
37
37
  it("should not decode percentages in URI") {
38
38
  let input = "file:///document/hello%2Fworld.txt"
39
39
  let unexpectedOutput = "file:///document/hello/world.txt"
40
- let uri = fileSystem.percentEncodedURL(fromURIString:input)
40
+ let uri = fileSystem.percentEncodedURL(fromURIString: input)
41
41
 
42
42
  // Should not create a directory named "hello"
43
43
  expect(uri?.absoluteString) != unexpectedOutput
@@ -45,7 +45,7 @@ class EXFileSystemSpec: ExpoSpec {
45
45
 
46
46
  it("should handle assets-library URIs") {
47
47
  let assetsLibraryUriInput = "assets-library://asset/asset.JPG?id=3C1D9C54-9521-488F-BB27-AA1EA0F8AF04/L0/001&ext=JPG"
48
- let assetsLibraryUri = fileSystem.percentEncodedURL(fromURIString:assetsLibraryUriInput)
48
+ let assetsLibraryUri = fileSystem.percentEncodedURL(fromURIString: assetsLibraryUriInput)
49
49
 
50
50
  expect(assetsLibraryUri?.absoluteString) == assetsLibraryUriInput
51
51
  expect(assetsLibraryUri?.scheme) == "assets-library"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-file-system",
3
- "version": "16.0.5",
3
+ "version": "16.0.7",
4
4
  "description": "Provides access to the local file system on the device.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -41,5 +41,5 @@
41
41
  "peerDependencies": {
42
42
  "expo": "*"
43
43
  },
44
- "gitHead": "7fb94e3c0598d0e2d428184b16eec5ec67d80388"
44
+ "gitHead": "e94e2aac39cbb7f8788869cd6798bbdeadc58f42"
45
45
  }