Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 65 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
)
}
}
```
Expand Down Expand Up @@ -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<Bool>, allowedContentTypes: [UTType], selectedDocumentURL: Binding<URL?>, selectedFilename: Binding<String?>, selectedFileMimeType: Binding<String?>)` 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<Bool>, allowedContentTypes: [UTType], selectedDocumentURL: Binding<URL?>, selectedFilename: Binding<String?>, selectedFileMimeType: Binding<String?>)` 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.

Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
207 changes: 148 additions & 59 deletions Sources/SkipKit/DocumentPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Bool>, allowedContentTypes: [UTType], selectedDocumentURL: Binding<URL?>, selectedFilename: Binding<String?>, selectedFileMimeType: Binding<String?> ) -> 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<Bool>, 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)
Expand All @@ -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
2 changes: 1 addition & 1 deletion Sources/SkipKit/DocumentPreviewViewer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading