@capgo/capacitor-file-picker 8.0.0

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.
@@ -0,0 +1,393 @@
1
+ import AVFoundation
2
+ import Capacitor
3
+ import Foundation
4
+ import MobileCoreServices
5
+ import PhotosUI
6
+ import UniformTypeIdentifiers
7
+
8
+ /**
9
+ * Please read the Capacitor iOS Plugin Development Guide
10
+ * here: https://capacitorjs.com/docs/plugins/ios
11
+ */
12
+ @objc(CapgoFilePickerPlugin)
13
+ public class CapgoFilePickerPlugin: CAPPlugin, CAPBridgedPlugin {
14
+ private let pluginVersion: String = "8.0.0"
15
+ public let identifier = "CapgoFilePickerPlugin"
16
+ public let jsName = "CapgoFilePicker"
17
+ public let pluginMethods: [CAPPluginMethod] = [
18
+ CAPPluginMethod(name: "pickFiles", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "pickImages", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "pickVideos", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "pickMedia", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "pickDirectory", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "convertHeicToJpeg", returnType: CAPPluginReturnPromise),
24
+ CAPPluginMethod(name: "copyFile", returnType: CAPPluginReturnPromise),
25
+ CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
26
+ CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
27
+ CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise)
28
+ ]
29
+
30
+ private var pendingCall: CAPPluginCall?
31
+ private var readData: Bool = false
32
+ private var skipTranscoding: Bool = false
33
+
34
+ // MARK: - Pick Files
35
+
36
+ @objc func pickFiles(_ call: CAPPluginCall) {
37
+ let types = call.getArray("types", String.self) ?? []
38
+ let limit = call.getInt("limit") ?? 0
39
+ readData = call.getBool("readData") ?? false
40
+
41
+ DispatchQueue.main.async {
42
+ self.pendingCall = call
43
+ let documentTypes = self.convertToUTTypes(from: types)
44
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: documentTypes)
45
+ picker.delegate = self
46
+ picker.allowsMultipleSelection = limit != 1
47
+ self.bridge?.viewController?.present(picker, animated: true)
48
+ }
49
+ }
50
+
51
+ // MARK: - Pick Images
52
+
53
+ @objc func pickImages(_ call: CAPPluginCall) {
54
+ pickMedia(call, filter: .images)
55
+ }
56
+
57
+ // MARK: - Pick Videos
58
+
59
+ @objc func pickVideos(_ call: CAPPluginCall) {
60
+ pickMedia(call, filter: .videos)
61
+ }
62
+
63
+ // MARK: - Pick Media
64
+
65
+ @objc func pickMedia(_ call: CAPPluginCall) {
66
+ pickMedia(call, filter: nil)
67
+ }
68
+
69
+ private func pickMedia(_ call: CAPPluginCall, filter: PHPickerFilter?) {
70
+ let limit = call.getInt("limit") ?? 0
71
+ readData = call.getBool("readData") ?? false
72
+ skipTranscoding = call.getBool("skipTranscoding") ?? false
73
+ let ordered = call.getBool("ordered") ?? false
74
+
75
+ DispatchQueue.main.async {
76
+ self.pendingCall = call
77
+ var config = PHPickerConfiguration(photoLibrary: .shared())
78
+ config.selectionLimit = limit
79
+ if let filter = filter {
80
+ config.filter = filter
81
+ } else {
82
+ config.filter = .any(of: [.images, .videos])
83
+ }
84
+ if #available(iOS 15.0, *), ordered {
85
+ config.selection = .ordered
86
+ }
87
+ let picker = PHPickerViewController(configuration: config)
88
+ picker.delegate = self
89
+ self.bridge?.viewController?.present(picker, animated: true)
90
+ }
91
+ }
92
+
93
+ // MARK: - Pick Directory
94
+
95
+ @objc func pickDirectory(_ call: CAPPluginCall) {
96
+ DispatchQueue.main.async {
97
+ self.pendingCall = call
98
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
99
+ picker.delegate = self
100
+ self.bridge?.viewController?.present(picker, animated: true)
101
+ }
102
+ }
103
+
104
+ // MARK: - Convert HEIC to JPEG
105
+
106
+ @objc func convertHeicToJpeg(_ call: CAPPluginCall) {
107
+ guard let path = call.getString("path") else {
108
+ call.reject("path is required")
109
+ return
110
+ }
111
+ let quality = call.getFloat("quality") ?? 0.9
112
+
113
+ DispatchQueue.global(qos: .userInitiated).async {
114
+ let url = URL(fileURLWithPath: path)
115
+ guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
116
+ let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else {
117
+ call.reject("Failed to load image")
118
+ return
119
+ }
120
+
121
+ let uiImage = UIImage(cgImage: cgImage)
122
+ guard let jpegData = uiImage.jpegData(compressionQuality: CGFloat(quality)) else {
123
+ call.reject("Failed to convert to JPEG")
124
+ return
125
+ }
126
+
127
+ let outputPath = path.replacingOccurrences(of: ".heic", with: ".jpg", options: .caseInsensitive)
128
+ let outputURL = URL(fileURLWithPath: outputPath)
129
+
130
+ do {
131
+ try jpegData.write(to: outputURL)
132
+ call.resolve(["path": outputPath])
133
+ } catch {
134
+ call.reject("Failed to write JPEG: \(error.localizedDescription)")
135
+ }
136
+ }
137
+ }
138
+
139
+ // MARK: - Copy File
140
+
141
+ @objc func copyFile(_ call: CAPPluginCall) {
142
+ guard let from = call.getString("from"), let to = call.getString("to") else {
143
+ call.reject("from and to are required")
144
+ return
145
+ }
146
+ let overwrite = call.getBool("overwrite") ?? false
147
+
148
+ DispatchQueue.global(qos: .userInitiated).async {
149
+ let fileManager = FileManager.default
150
+ let fromURL = URL(fileURLWithPath: from)
151
+ let toURL = URL(fileURLWithPath: to)
152
+
153
+ do {
154
+ if fileManager.fileExists(atPath: to) {
155
+ if overwrite {
156
+ try fileManager.removeItem(at: toURL)
157
+ } else {
158
+ call.reject("Destination file already exists")
159
+ return
160
+ }
161
+ }
162
+ try fileManager.copyItem(at: fromURL, to: toURL)
163
+ call.resolve()
164
+ } catch {
165
+ call.reject("Failed to copy file: \(error.localizedDescription)")
166
+ }
167
+ }
168
+ }
169
+
170
+ // MARK: - Permissions
171
+
172
+ override public func checkPermissions(_ call: CAPPluginCall) {
173
+ let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
174
+ call.resolve(["readExternalStorage": permissionStateString(from: status)])
175
+ }
176
+
177
+ override public func requestPermissions(_ call: CAPPluginCall) {
178
+ PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
179
+ call.resolve(["readExternalStorage": self.permissionStateString(from: status)])
180
+ }
181
+ }
182
+
183
+ private func permissionStateString(from status: PHAuthorizationStatus) -> String {
184
+ switch status {
185
+ case .authorized, .limited:
186
+ return "granted"
187
+ case .denied, .restricted:
188
+ return "denied"
189
+ case .notDetermined:
190
+ return "prompt"
191
+ @unknown default:
192
+ return "prompt"
193
+ }
194
+ }
195
+
196
+ // MARK: - Plugin Version
197
+
198
+ @objc func getPluginVersion(_ call: CAPPluginCall) {
199
+ call.resolve(["version": self.pluginVersion])
200
+ }
201
+
202
+ // MARK: - Helpers
203
+
204
+ private func convertToUTTypes(from types: [String]) -> [UTType] {
205
+ if types.isEmpty {
206
+ return [.item]
207
+ }
208
+ var utTypes: [UTType] = []
209
+ for type in types {
210
+ if type == "*/*" {
211
+ return [.item]
212
+ } else if type.hasSuffix("/*") {
213
+ let prefix = String(type.dropLast(2))
214
+ switch prefix {
215
+ case "image":
216
+ utTypes.append(.image)
217
+ case "video":
218
+ utTypes.append(.movie)
219
+ case "audio":
220
+ utTypes.append(.audio)
221
+ case "text":
222
+ utTypes.append(.text)
223
+ default:
224
+ utTypes.append(.item)
225
+ }
226
+ } else if type.hasPrefix(".") {
227
+ if let utType = UTType(filenameExtension: String(type.dropFirst())) {
228
+ utTypes.append(utType)
229
+ }
230
+ } else if let utType = UTType(mimeType: type) {
231
+ utTypes.append(utType)
232
+ }
233
+ }
234
+ return utTypes.isEmpty ? [.item] : utTypes
235
+ }
236
+
237
+ private func getFileInfo(from url: URL, readData: Bool) -> [String: Any] {
238
+ var result: [String: Any] = [
239
+ "name": url.lastPathComponent,
240
+ "path": url.path
241
+ ]
242
+
243
+ if let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType {
244
+ result["mimeType"] = mimeType
245
+ } else {
246
+ result["mimeType"] = "application/octet-stream"
247
+ }
248
+
249
+ if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) {
250
+ if let size = attributes[.size] as? Int64 {
251
+ result["size"] = size
252
+ }
253
+ if let modifiedDate = attributes[.modificationDate] as? Date {
254
+ result["modifiedAt"] = Int64(modifiedDate.timeIntervalSince1970 * 1000)
255
+ }
256
+ }
257
+
258
+ if readData, let data = try? Data(contentsOf: url) {
259
+ result["data"] = data.base64EncodedString()
260
+ }
261
+
262
+ // Get dimensions for images
263
+ if let mimeType = result["mimeType"] as? String, mimeType.hasPrefix("image/") {
264
+ if let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
265
+ let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any] {
266
+ if let width = properties[kCGImagePropertyPixelWidth as String] as? Int {
267
+ result["width"] = width
268
+ }
269
+ if let height = properties[kCGImagePropertyPixelHeight as String] as? Int {
270
+ result["height"] = height
271
+ }
272
+ }
273
+ }
274
+
275
+ return result
276
+ }
277
+ }
278
+
279
+ // MARK: - UIDocumentPickerDelegate
280
+
281
+ extension CapgoFilePickerPlugin: UIDocumentPickerDelegate {
282
+ public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
283
+ guard let call = pendingCall else { return }
284
+ pendingCall = nil
285
+
286
+ // Check if this was a directory picker
287
+ if urls.count == 1, urls[0].hasDirectoryPath {
288
+ let granted = urls[0].startAccessingSecurityScopedResource()
289
+ if granted {
290
+ call.resolve(["path": urls[0].path])
291
+ urls[0].stopAccessingSecurityScopedResource()
292
+ } else {
293
+ call.resolve(["path": urls[0].path])
294
+ }
295
+ return
296
+ }
297
+
298
+ var files: [[String: Any]] = []
299
+ for url in urls {
300
+ let granted = url.startAccessingSecurityScopedResource()
301
+ defer { if granted { url.stopAccessingSecurityScopedResource() } }
302
+ files.append(getFileInfo(from: url, readData: readData))
303
+ }
304
+ call.resolve(["files": files])
305
+ }
306
+
307
+ public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
308
+ guard let call = pendingCall else { return }
309
+ pendingCall = nil
310
+ notifyListeners("pickerDismissed", data: [:])
311
+ call.resolve(["files": []])
312
+ }
313
+ }
314
+
315
+ // MARK: - PHPickerViewControllerDelegate
316
+
317
+ extension CapgoFilePickerPlugin: PHPickerViewControllerDelegate {
318
+ public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
319
+ picker.dismiss(animated: true)
320
+
321
+ guard let call = pendingCall else { return }
322
+
323
+ if results.isEmpty {
324
+ pendingCall = nil
325
+ notifyListeners("pickerDismissed", data: [:])
326
+ call.resolve(["files": []])
327
+ return
328
+ }
329
+
330
+ let group = DispatchGroup()
331
+ var files: [[String: Any]] = []
332
+ let filesLock = NSLock()
333
+
334
+ for result in results {
335
+ group.enter()
336
+
337
+ let itemProvider = result.itemProvider
338
+
339
+ // Determine the UTType to load
340
+ let typeIdentifier: String
341
+ if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
342
+ typeIdentifier = UTType.movie.identifier
343
+ } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
344
+ typeIdentifier = UTType.image.identifier
345
+ } else {
346
+ typeIdentifier = itemProvider.registeredTypeIdentifiers.first ?? UTType.data.identifier
347
+ }
348
+
349
+ itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] url, _ in
350
+ guard let self = self, let url = url else {
351
+ group.leave()
352
+ return
353
+ }
354
+
355
+ // Copy to temp directory to keep the file accessible
356
+ let tempDir = FileManager.default.temporaryDirectory
357
+ let tempURL = tempDir.appendingPathComponent(UUID().uuidString + "_" + url.lastPathComponent)
358
+
359
+ do {
360
+ try FileManager.default.copyItem(at: url, to: tempURL)
361
+ var fileInfo = self.getFileInfo(from: tempURL, readData: self.readData)
362
+
363
+ // Get video duration if applicable
364
+ if let mimeType = fileInfo["mimeType"] as? String, mimeType.hasPrefix("video/") {
365
+ let asset = AVURLAsset(url: tempURL)
366
+ let duration = CMTimeGetSeconds(asset.duration)
367
+ if duration.isFinite {
368
+ fileInfo["duration"] = duration
369
+ }
370
+ // Get video dimensions
371
+ if let track = asset.tracks(withMediaType: .video).first {
372
+ let size = track.naturalSize.applying(track.preferredTransform)
373
+ fileInfo["width"] = abs(Int(size.width))
374
+ fileInfo["height"] = abs(Int(size.height))
375
+ }
376
+ }
377
+
378
+ filesLock.lock()
379
+ files.append(fileInfo)
380
+ filesLock.unlock()
381
+ } catch {
382
+ // Silently fail for individual files
383
+ }
384
+ group.leave()
385
+ }
386
+ }
387
+
388
+ group.notify(queue: .main) { [weak self] in
389
+ self?.pendingCall = nil
390
+ call.resolve(["files": files])
391
+ }
392
+ }
393
+ }
@@ -0,0 +1,11 @@
1
+ import XCTest
2
+ @testable import CapgoFilePickerPlugin
3
+
4
+ final class CapgoFilePickerPluginTests: XCTestCase {
5
+ func testPluginLoads() throws {
6
+ let plugin = CapgoFilePickerPlugin()
7
+ XCTAssertNotNil(plugin)
8
+ XCTAssertEqual(plugin.identifier, "CapgoFilePickerPlugin")
9
+ XCTAssertEqual(plugin.jsName, "CapgoFilePicker")
10
+ }
11
+ }
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "@capgo/capacitor-file-picker",
3
+ "version": "8.0.0",
4
+ "description": "File picker Capacitor plugin - Pick files, images, videos, and directories",
5
+ "main": "dist/plugin.cjs.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/esm/index.d.ts",
8
+ "unpkg": "dist/plugin.js",
9
+ "files": [
10
+ "android/src/main/",
11
+ "android/build.gradle",
12
+ "dist/",
13
+ "ios/Sources",
14
+ "ios/Tests",
15
+ "Package.swift",
16
+ "CapgoCapacitorFilePicker.podspec"
17
+ ],
18
+ "author": "Martin Donadieu <martin@capgo.app>",
19
+ "license": "MPL-2.0",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Cap-go/capacitor-file-picker.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/Cap-go/capacitor-file-picker/issues"
26
+ },
27
+ "keywords": [
28
+ "capacitor",
29
+ "plugin",
30
+ "native",
31
+ "file",
32
+ "picker",
33
+ "image",
34
+ "video",
35
+ "media",
36
+ "gallery"
37
+ ],
38
+ "scripts": {
39
+ "verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
40
+ "verify:ios": "xcodebuild -scheme CapgoCapacitorFilePicker -destination generic/platform=iOS",
41
+ "verify:android": "cd android && ./gradlew clean build test && cd ..",
42
+ "verify:web": "npm run build",
43
+ "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
44
+ "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format",
45
+ "eslint": "eslint . --ext ts",
46
+ "prettier": "prettier \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
47
+ "swiftlint": "node-swiftlint",
48
+ "docgen": "docgen --api CapgoFilePickerPlugin --output-readme README.md --output-json dist/docs.json",
49
+ "build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
50
+ "clean": "rimraf ./dist",
51
+ "watch": "tsc --watch",
52
+ "prepublishOnly": "npm run build"
53
+ },
54
+ "devDependencies": {
55
+ "@capacitor/android": "^8.0.0",
56
+ "@capacitor/core": "^8.0.0",
57
+ "@capacitor/docgen": "^0.3.1",
58
+ "@capacitor/ios": "^8.0.0",
59
+ "@ionic/eslint-config": "^0.4.0",
60
+ "@ionic/prettier-config": "^4.0.0",
61
+ "@ionic/swiftlint-config": "^2.0.0",
62
+ "@types/node": "^24.10.1",
63
+ "eslint": "^8.57.1",
64
+ "eslint-plugin-import": "^2.31.0",
65
+ "prettier": "^3.6.2",
66
+ "prettier-plugin-java": "^2.7.7",
67
+ "rimraf": "^6.1.0",
68
+ "rollup": "^4.53.2",
69
+ "swiftlint": "^2.0.0",
70
+ "typescript": "^5.9.3"
71
+ },
72
+ "peerDependencies": {
73
+ "@capacitor/core": ">=8.0.0"
74
+ },
75
+ "prettier": "@ionic/prettier-config",
76
+ "swiftlint": "@ionic/swiftlint-config",
77
+ "eslintConfig": {
78
+ "extends": "@ionic/eslint-config/recommended"
79
+ },
80
+ "capacitor": {
81
+ "ios": {
82
+ "src": "ios"
83
+ },
84
+ "android": {
85
+ "src": "android"
86
+ }
87
+ }
88
+ }