diff --git a/README.md b/README.md index 460c2f9..c40a888 100644 --- a/README.md +++ b/README.md @@ -392,12 +392,13 @@ let granted = await PermissionManager.requestPermission("android.permission.SEND ## Camera and Media selection The `View.withMediaPicker(type:isPresented:selectedImageURL:)` extension function -can be used to enable the acquisition of an image from either the system camera -or the user's media library. +can be used to enable the acquisition of an image from either the system camera +or the user's media library. To support picking multiple images from the media +library, use `View.withMediaPicker(type:isPresented:allowsMultipleSelection:selectedImageURLs:)`. -On iOS, this camera selector will be presented in a `fullScreenCover` view, -whereas the media library browser will be presented in a `sheet`. In both cases, -a standad `UIImagePickerController` will be used to acquire the media. +On iOS, this camera selector will be presented in a `fullScreenCover` view, +whereas the media library browser will be presented in a `sheet`. The camera +uses `UIImagePickerController`, while the media library uses `PHPickerViewController`. On Android, the camera and library browser will be activated through an Intent after querying for the necessary permissions. @@ -421,7 +422,33 @@ struct MediaButton : View { Button(type == .camera ? "Take Photo" : "Select Media") { showPicker = true // activate the media picker } - .withMediaPicker(type: .camera, isPresented: $showPicker, selectedImageURL: $selectedImageURL) + .withMediaPicker( + type: type, + isPresented: $showPicker, + selectedImageURL: $selectedImageURL + ) + } +} +``` + +For multiple media library selections, bind to an array of URLs and pass +`allowsMultipleSelection: true`: + +```swift +struct MultiMediaButton: View { + @Binding var selectedImageURLs: [URL] + @State private var showPicker = false + + var body: some View { + Button("Select Media") { + showPicker = true + } + .withMediaPicker( + type: .library, + isPresented: $showPicker, + allowsMultipleSelection: true, + selectedImageURLs: $selectedImageURLs + ) } } ``` @@ -499,7 +526,7 @@ For an example of a properly configured project, see the Photo Chat sample appli ## Document Picker -The `View.withDocumentPicker(isPresented: Binding, allowedContentTypes: [UTType], selectedDocumentURL: Binding, selectedFilename: Binding, selectedFileMimeType: Binding)` extension function can be used to select a document of the specified UTType from the device to use in the App. +The `View.withDocumentPicker(isPresented: Binding, allowedContentTypes: [UTType], selectedDocumentURL: Binding, selectedFilename: Binding, selectedFileMimeType: Binding)` extension function can be used to select a document of the specified UTType from the device to use in the App. To support picking multiple documents, use `View.withDocumentPicker(isPresented:allowedContentTypes:allowsMultipleSelection:selectedDocumentURLs:selectedFilenames:selectedFileMimeTypes:)`. On iOS it will use an instance of `FileImporter` to display the system file picker, essentially allowing to select a file from the Files application, while on Android it relies on the the system document picker via the Activity result for the `ACTION_OPEN_DOCUMENT`. Once the user selects a file it will receive an `uri`, that need to be parsed to be used outside the scope of the caller. For doing so it will copy the file inside the App cache folder and expose the cached url instead of the original picked file url. @@ -510,9 +537,38 @@ Button("Pick Document") { presentPreview = true } .buttonStyle(.borderedProminent) -.withDocumentPicker(isPresented: $presentPreview, allowedContentTypes: [.image, .pdf], selectedDocumentURL: $selectedDocument, selectedFilename: $filename, selectedFileMimeType: $mimeType) +.withDocumentPicker( + isPresented: $presentPreview, + allowedContentTypes: [.image, .pdf], + selectedDocumentURL: $selectedDocument, + selectedFilename: $filename, + selectedFileMimeType: $mimeType +) +``` + +For multiple document selections, bind to arrays of selected URLs, filenames, +and MIME types: + +```swift +Button("Pick Documents") { + presentPreview = true +} +.buttonStyle(.borderedProminent) +.withDocumentPicker( + isPresented: $presentPreview, + allowedContentTypes: [.image, .pdf], + allowsMultipleSelection: true, + selectedDocumentURLs: $selectedDocuments, + selectedFilenames: $filenames, + selectedFileMimeTypes: $mimeTypes +) ``` +On Android, the system document picker controls the multiple-selection user +interface. Depending on the provider, tapping a document may immediately return +that single item; selecting multiple documents typically requires entering the +picker's selection mode, for example by long-pressing the first item. + ### Security-Scoped URLs on iOS On iOS, files selected through the system document picker are *security-scoped* — the app is only granted temporary access to read them. The `withDocumentPicker` modifier acquires and releases this access internally during the picker callback, but your `onChange` handler runs **after** the access has already been released. If you need to read or copy the file contents (e.g., importing it into your app's documents directory), you must re-acquire access in your handler: @@ -541,7 +597,7 @@ On iOS, files selected through the system document picker are *security-scoped* } ``` -This is only needed on iOS — Android handles file access differently by copying the selected file to the app's cache directory before returning the URL, so the `#if !SKIP` guard ensures the security-scoped calls are skipped on Android. +This is only needed on iOS — Android handles file access differently by copying the selected file to the app's cache directory before returning the URL, so the `#if !SKIP` guard ensures the security-scoped calls are skipped on Android. The same applies when using the array-based `selectedDocumentURLs` binding. If you only need to display the file (e.g., pass it to a `DocumentPreview`) without reading its contents, re-acquiring access may not be necessary. diff --git a/Sources/SkipKit/DocumentPicker.swift b/Sources/SkipKit/DocumentPicker.swift index 8a34cdb..db0de13 100644 --- a/Sources/SkipKit/DocumentPicker.swift +++ b/Sources/SkipKit/DocumentPicker.swift @@ -6,7 +6,6 @@ import SwiftUI #if SKIP import android.Manifest -import android.app.Activity import android.content.Context import android.content.pm.PackageManager import androidx.compose.runtime.mutableStateOf @@ -34,89 +33,129 @@ extension View { /// - filename: the filename of the selected file /// - mimeType: the mimeType of the selected file @ViewBuilder public func withDocumentPicker(isPresented: Binding, allowedContentTypes: [UTType], selectedDocumentURL: Binding, selectedFilename: Binding, selectedFileMimeType: Binding ) -> some View { + self.withDocumentPicker( + isPresented: isPresented, + allowedContentTypes: allowedContentTypes, + allowsMultipleSelection: false, + selectedDocumentURLs: Binding( + get: { + if let value = selectedDocumentURL.wrappedValue { + return [value] + } + return [] + }, + set: { selectedDocumentURL.wrappedValue = $0.first } + ), + selectedFilenames: Binding( + get: { + if let value = selectedFilename.wrappedValue { + return [value] + } + return [] + }, + set: { selectedFilename.wrappedValue = $0.first } + ), + selectedFileMimeTypes: Binding( + get: { + if let value = selectedFileMimeType.wrappedValue { + return [value] + } + return [] + }, + set: { selectedFileMimeType.wrappedValue = $0.first } + ) + ) + } + + /// Allow to present a Document picker interface activated by the `isPresented` binding. It will return the selected file URLs through the `selectedDocumentURLs` binding. + /// + /// On iOS uses the `fileImporter` with allowed content types of `text`, `pdf` and `images`. + /// On Android it will use the intent action ACTION_OPEN_DOCUMENT to present the system picker for `pdf` and `images`. + /// It optionally also returns the real `filename` and `mimeType` values through the corresponding bindings, since on this platform the document pickers returns an obfuscated url. Also, on Android, in order for the url to be accessible outside the scope of this call a copy of the file is made in the cache directory, and the copied file url is returned + /// - Parameters: + /// - isPresented: binding for presentation + /// - allowsMultipleSelection: whether multiple documents can be selected + /// - selectedDocumentURLs: the URLs of the selected files + /// - selectedFilenames: the filenames of the selected files + /// - selectedFileMimeTypes: the mimeTypes of the selected files + @ViewBuilder public func withDocumentPicker(isPresented: Binding, allowedContentTypes: [UTType], allowsMultipleSelection: Bool, selectedDocumentURLs: Binding<[URL]>, selectedFilenames: Binding<[String]>, selectedFileMimeTypes: Binding<[String]> ) -> some View { #if SKIP let context = LocalContext.current - + let pickDocumentLauncher = rememberLauncherForActivityResult(contract: ActivityResultContracts.OpenDocument()) { uri in isPresented.wrappedValue = false logger.log(message: "selected document uri: \(uri)") if let uri = uri { - let resolver = context.contentResolver - var resolvedName: String? = nil - var resolvedMime: String? = nil - - if let query = resolver.query(uri, nil, nil, nil, nil) { - if query.moveToFirst() { - // Downloads provider omits these columns; tolerate -1. - let nameIndex = query.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if nameIndex >= 0 { - resolvedName = query.getString(nameIndex) - } - let mimeIndex = query.getColumnIndex(android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE) - if mimeIndex >= 0 { - resolvedMime = query.getString(mimeIndex) - } - } - query.close() - } - - if resolvedMime == nil { - resolvedMime = resolver.getType(uri) + let result = resolvePickedDocument(uri: uri, context: context) + selectedFilenames.wrappedValue = [result.filename] + selectedFileMimeTypes.wrappedValue = [result.mimeType ?? ""] + if let url = result.url { + selectedDocumentURLs.wrappedValue = [url] + } else { + selectedDocumentURLs.wrappedValue = [] } + } + } - let safeName: String = resolvedName ?? "import-\(java.util.UUID.randomUUID().toString())" - - selectedFilename.wrappedValue = safeName - selectedFileMimeType.wrappedValue = resolvedMime + let pickDocumentsLauncher = rememberLauncherForActivityResult(contract: ActivityResultContracts.OpenMultipleDocuments()) { uris in + isPresented.wrappedValue = false + var urls = [URL]() + var filenames = [String]() + var mimeTypes = [String]() - // java.io.File path avoids Skip URL.appendingPathComponent NPE. - if let cacheDir = context.cacheDir { - let destinationFile = java.io.File(cacheDir, safeName) - if destinationFile.exists() { - destinationFile.delete() - } - if let inputStream = resolver.openInputStream(uri) { - let outputStream = java.io.FileOutputStream(destinationFile) - inputStream.copyTo(outputStream) - outputStream.close() - inputStream.close() - // File.toURI() percent-encodes; raw path would crash java.net.URI. - selectedDocumentURL.wrappedValue = URL(platformValue: destinationFile.toURI()) - } else { - selectedDocumentURL.wrappedValue = URL(platformValue: java.net.URI.create(uri.toString())) - } - } else { - selectedDocumentURL.wrappedValue = URL(platformValue: java.net.URI.create(uri.toString())) + for uri in uris { + let result = resolvePickedDocument(uri: uri, context: context, uniqueDestinationName: true) + if let url = result.url { + urls.append(url) + filenames.append(result.filename) + mimeTypes.append(result.mimeType ?? "") } } + + selectedDocumentURLs.wrappedValue = urls + selectedFilenames.wrappedValue = filenames + selectedFileMimeTypes.wrappedValue = mimeTypes } - + return onChange(of: isPresented.wrappedValue) { oldValue, presented in if presented == true { let parsedMimeTypes: [String] = allowedContentTypes.map({ $0.preferredMIMEType ?? ""}) - var types = kotlin.arrayOf("") + var types = kotlin.arrayOf("*/*") for type in parsedMimeTypes { - types += type + if type.isEmpty == false { + types += type + } } let mimeTypes = types //kotlin.arrayOf("application/pdf", "image/*") - pickDocumentLauncher.launch(mimeTypes) + isPresented.wrappedValue = false + if allowsMultipleSelection { + pickDocumentsLauncher.launch(mimeTypes) + } else { + pickDocumentLauncher.launch(mimeTypes) + } } } #else // !SKIP - fileImporter(isPresented: isPresented, allowedContentTypes: allowedContentTypes) { result in + fileImporter(isPresented: isPresented, allowedContentTypes: allowedContentTypes, allowsMultipleSelection: allowsMultipleSelection) { result in switch result { - case .success(let file): - // gain access to the directory - let gotAccess = file.startAccessingSecurityScopedResource() - if !gotAccess { return } - // access the directory URL - // (read templates in the directory, make a bookmark, etc.) - selectedDocumentURL.wrappedValue = file + case .success(let files): + var selectedFiles = [URL]() + for file in files { + // gain access to the directory + let gotAccess = file.startAccessingSecurityScopedResource() + if !gotAccess { continue } + // access the directory URL + // (read templates in the directory, make a bookmark, etc.) + selectedFiles.append(file) + // release access + file.stopAccessingSecurityScopedResource() + } + selectedDocumentURLs.wrappedValue = selectedFiles + selectedFilenames.wrappedValue = selectedFiles.map { $0.lastPathComponent } + selectedFileMimeTypes.wrappedValue = Array(repeating: "", count: selectedFiles.count) isPresented.wrappedValue = false - // release access - file.stopAccessingSecurityScopedResource() case .failure(let error): // handle error print(error) @@ -126,4 +165,54 @@ extension View { #endif } } + +#if SKIP +private func resolvePickedDocument(uri: android.net.Uri, context: Context, uniqueDestinationName: Bool = false) -> (url: URL?, filename: String, mimeType: String?) { + let resolver = context.contentResolver + var resolvedName: String? = nil + var resolvedMime: String? = nil + + if let query = resolver.query(uri, nil, nil, nil, nil) { + if query.moveToFirst() { + // Downloads provider omits these columns; tolerate -1. + let nameIndex = query.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if nameIndex >= 0 { + resolvedName = query.getString(nameIndex) + } + let mimeIndex = query.getColumnIndex(android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE) + if mimeIndex >= 0 { + resolvedMime = query.getString(mimeIndex) + } + } + query.close() + } + + if resolvedMime == nil { + resolvedMime = resolver.getType(uri) + } + + let safeName: String = resolvedName ?? "import-\(java.util.UUID.randomUUID().toString())" + let destinationName: String = uniqueDestinationName ? "\(java.util.UUID.randomUUID().toString())-\(safeName)" : safeName + + // java.io.File path avoids Skip URL.appendingPathComponent NPE. + if let cacheDir = context.cacheDir { + let destinationFile = java.io.File(cacheDir, destinationName) + if destinationFile.exists() { + destinationFile.delete() + } + if let inputStream = resolver.openInputStream(uri) { + let outputStream = java.io.FileOutputStream(destinationFile) + inputStream.copyTo(outputStream) + outputStream.close() + inputStream.close() + // File.toURI() percent-encodes; raw path would crash java.net.URI. + return (URL(platformValue: destinationFile.toURI()), safeName, resolvedMime) + } else { + return (URL(platformValue: java.net.URI.create(uri.toString())), safeName, resolvedMime) + } + } else { + return (URL(platformValue: java.net.URI.create(uri.toString())), safeName, resolvedMime) + } +} +#endif #endif diff --git a/Sources/SkipKit/DocumentPreviewViewer.swift b/Sources/SkipKit/DocumentPreviewViewer.swift index c432a0a..0c76d71 100644 --- a/Sources/SkipKit/DocumentPreviewViewer.swift +++ b/Sources/SkipKit/DocumentPreviewViewer.swift @@ -44,7 +44,7 @@ extension View { return onChange(of: isPresented.wrappedValue) { presented in if presented == true { - let file = java.io.File.init(documentURL!.absoluteString) + let file = java.io.File.init(documentURL!.path) let uri = androidx.core.content.FileProvider.getUriForFile(context.asActivity(), context.getPackageName() + ".fileprovider", file) var mimeType: String diff --git a/Sources/SkipKit/MediaPicker.swift b/Sources/SkipKit/MediaPicker.swift index cd47fd6..13dbbcf 100644 --- a/Sources/SkipKit/MediaPicker.swift +++ b/Sources/SkipKit/MediaPicker.swift @@ -23,6 +23,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat.startActivity #endif +#if !SKIP && os(iOS) +import PhotosUI +#endif + public enum MediaPickerType { case camera, library } @@ -53,8 +57,15 @@ extension View { #if !SKIP #if os(iOS) sheet(isPresented: isPresented) { - PhotoLibraryPicker(sourceType: .photoLibrary, selectedImageURL: selectedImageURL) - .presentationDetents([.medium]) + PhotoLibraryPicker(allowsMultipleSelection: false, selectedImageURLs: Binding( + get: { + if let value = selectedImageURL.wrappedValue { + return [value] + } + return [] + }, + set: { selectedImageURL.wrappedValue = $0.first } + )) } #endif #else @@ -69,6 +80,7 @@ extension View { return onChange(of: isPresented.wrappedValue) { presented in if presented == true { + isPresented.wrappedValue = false pickImageLauncher.launch("image/*") } } @@ -78,7 +90,8 @@ extension View { #if !SKIP #if os(iOS) fullScreenCover(isPresented: isPresented) { - PhotoLibraryPicker(sourceType: .camera, selectedImageURL: selectedImageURL) + CameraImagePicker(selectedImageURL: selectedImageURL) + .ignoresSafeArea() } #endif #else @@ -109,6 +122,7 @@ extension View { ActivityCompat.requestPermissions(context.asActivity(), perms, PERM_REQUEST_CAMERA) isPresented.wrappedValue = false } else { + isPresented.wrappedValue = false let storageDir = context.getExternalFilesDir(android.os.Environment.DIRECTORY_PICTURES) let ext = ".jpg" let tmpFile = java.io.File.createTempFile("SkipKit_\(UUID().uuidString)", ext, storageDir) @@ -124,19 +138,80 @@ extension View { #endif } } + + /// Enables a media picker interface for the camera or photo library can be activated through the `isPresented` binding, and which returns selected images through the `selectedImageURLs` binding. + /// + /// On iOS and Android, `allowsMultipleSelection` only applies to the photo library. + @ViewBuilder public func withMediaPicker(type: MediaPickerType, isPresented: Binding, allowsMultipleSelection: Bool, selectedImageURLs: Binding<[URL]>) -> some View { + switch type { + case .library: + #if !SKIP + #if os(iOS) + sheet(isPresented: isPresented) { + PhotoLibraryPicker(allowsMultipleSelection: allowsMultipleSelection, selectedImageURLs: selectedImageURLs) + } + #endif + #else + if allowsMultipleSelection { + let pickImagesLauncher = rememberLauncherForActivityResult(contract: ActivityResultContracts.GetMultipleContents()) { uris in + isPresented.wrappedValue = false + var urls = [URL]() + for uri in uris { + urls.append(URL(platformValue: java.net.URI.create(uri.toString()))) + } + selectedImageURLs.wrappedValue = urls + } + + return onChange(of: isPresented.wrappedValue) { presented in + if presented == true { + isPresented.wrappedValue = false + pickImagesLauncher.launch("image/*") + } + } + } else { + let pickImageLauncher = rememberLauncherForActivityResult(contract: ActivityResultContracts.GetContent()) { uri in + isPresented.wrappedValue = false // clear the presented bit + logger.log("pickImageLauncher: \(uri)") + if let uri = uri { + selectedImageURLs.wrappedValue = [URL(platformValue: java.net.URI.create(uri.toString()))] + } + } + + return onChange(of: isPresented.wrappedValue) { presented in + if presented == true { + isPresented.wrappedValue = false + pickImageLauncher.launch("image/*") + } + } + } + #endif + + case .camera: + withMediaPicker(type: type, isPresented: isPresented, selectedImageURL: Binding( + get: { selectedImageURLs.wrappedValue.first }, + set: { + if let value = $0 { + selectedImageURLs.wrappedValue = [value] + } else { + selectedImageURLs.wrappedValue = [] + } + } + )) + } + } } #if !SKIP #if os(iOS) -struct PhotoLibraryPicker: UIViewControllerRepresentable { - let sourceType: UIImagePickerController.SourceType +struct CameraImagePicker: UIViewControllerRepresentable { @Binding var selectedImageURL: URL? @Environment(\.dismiss) var dismiss func makeUIViewController(context: Context) -> UIImagePickerController { let imagePicker = UIImagePickerController() imagePicker.delegate = context.coordinator - imagePicker.sourceType = sourceType + imagePicker.sourceType = .camera + imagePicker.view.backgroundColor = .black return imagePicker } @@ -149,9 +224,9 @@ struct PhotoLibraryPicker: UIViewControllerRepresentable { } class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { - let parent: PhotoLibraryPicker + let parent: CameraImagePicker - init(_ parent: PhotoLibraryPicker) { + init(_ parent: CameraImagePicker) { self.parent = parent } @@ -189,6 +264,89 @@ struct PhotoLibraryPicker: UIViewControllerRepresentable { } } } + +struct PhotoLibraryPicker: UIViewControllerRepresentable { + let allowsMultipleSelection: Bool + @Binding var selectedImageURLs: [URL] + @Environment(\.dismiss) var dismiss + + func makeUIViewController(context: Context) -> PHPickerViewController { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = .images + configuration.selectionLimit = allowsMultipleSelection ? 0 : 1 + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { + + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: PhotoLibraryPicker + + init(_ parent: PhotoLibraryPicker) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard !results.isEmpty else { + self.parent.dismiss() + return + } + + let dispatchGroup = DispatchGroup() + var selectedURLs = Array(repeating: nil, count: results.count) + let lock = NSLock() + + for (index, result) in results.enumerated() { + let provider = result.itemProvider + guard provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) else { + continue + } + + dispatchGroup.enter() + provider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in + defer { dispatchGroup.leave() } + + if let error { + logger.warning("PhotoLibraryPicker load error: \(error)") + return + } + + guard let url else { + return + } + + let fileExtension = url.pathExtension.isEmpty ? "jpg" : url.pathExtension + let destinationURL = URL.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(fileExtension) + + do { + try FileManager.default.copyItem(at: url, to: destinationURL) + lock.lock() + selectedURLs[index] = destinationURL + lock.unlock() + } catch { + logger.warning("PhotoLibraryPicker copy error: \(error)") + } + } + } + + dispatchGroup.notify(queue: .main) { + self.parent.selectedImageURLs = selectedURLs.compactMap { $0 } + self.parent.dismiss() + } + } + } +} #endif #endif #endif