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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,38 @@ This is only needed on iOS — Android handles file access differently by copyin

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.

## Document Exporter

The `View.withDocumentExporter(isPresented:contentType:documentURL:onCompletion:)` extension function can be used to export a local file to a user-selected destination.

On iOS, it uses SwiftUI's `fileExporter` to display the system export dialog. On Android, it uses the system document creator via `ACTION_CREATE_DOCUMENT` and copies the source file to the selected location.

For example:

```swift
Button("Export Photo") {
if let url = Bundle.module.url(forResource: "photo", withExtension: "png") {
exportDocumentURL = url
presentDocumentExporter = true
}
}
.buttonStyle(.borderedProminent)
.withDocumentExporter(
isPresented: $presentDocumentExporter,
contentType: .png,
documentURL: exportDocumentURL
) { result in
switch result {
case .success(let url):
print("Exported to \(url)")
case .failure(let error):
print("Export failed: \(error)")
}
}
```

On Android, `documentURL` must point to a real file that the app can read. Bundle resources may resolve to `jar:file:` URLs inside the APK, so copy bundled data to the app cache before exporting it.

## Mail Composer

The `View.withMailComposer()` modifier presents a system email composition interface, allowing users to compose and send emails from within your app.
Expand Down
167 changes: 139 additions & 28 deletions Sources/SkipKit/DocumentPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,27 @@ import androidx.core.content.ContextCompat.startActivity
#endif

extension View {
/// Allow to present a Document picker interface activated by the `isPresented` binding. It will return the selected file URL through the `selectedDocumentURL` binding.

/// Allows presenting a document picker interface activated by the `isPresented` binding.
///
/// On iOS, this uses `fileImporter` with the supplied content types. On Android, this uses the
/// `ACTION_OPEN_DOCUMENT` intent. Android returns an obfuscated URL, so the selected document is
/// copied into the app cache and the copied file URL is returned together with its filename and MIME type.
///
/// On iOS uses the `fileImporter` with allowed content types of `text`, `pdf` and `images`.
/// On Android it will user the intet action ACTION_OPEN_DOCUMENT to present the system picker for `pdf` and `images`.
/// It optionally also returns the real `filename` and `mimeType` 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
/// - selectedDocumentURL: the URL of the selected file
/// - 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 {
/// - isPresented: Binding for presentation.
/// - allowedContentTypes: The content types that can be selected.
/// - selectedDocumentURL: The URL of the selected file.
/// - selectedFilename: The filename of the selected file.
/// - selectedFileMimeType: The MIME type 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,
Expand Down Expand Up @@ -67,18 +77,28 @@ extension View {
)
}

/// Allow to present a Document picker interface activated by the `isPresented` binding. It will return the selected file URLs through the `selectedDocumentURLs` binding.
/// Allows presenting a document picker interface activated by the `isPresented` binding.
///
/// On iOS, this uses `fileImporter` with the supplied content types. On Android, this uses the
/// `ACTION_OPEN_DOCUMENT` intent. Android returns obfuscated URLs, so selected documents are
/// copied into the app cache and the copied file URLs are returned together with their filenames and MIME types.
///
/// 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 {
/// - isPresented: Binding for presentation.
/// - allowedContentTypes: The content types that can be selected.
/// - allowsMultipleSelection: Whether multiple documents can be selected.
/// - selectedDocumentURLs: The URLs of the selected files.
/// - selectedFilenames: The filenames of the selected files.
/// - selectedFileMimeTypes: The MIME types 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

Expand Down Expand Up @@ -119,14 +139,14 @@ extension View {

return onChange(of: isPresented.wrappedValue) { oldValue, presented in
if presented == true {
let parsedMimeTypes: [String] = allowedContentTypes.map({ $0.preferredMIMEType ?? ""})
let parsedMimeTypes: [String] = allowedContentTypes.map { $0.preferredMIMEType ?? "" }
var types = kotlin.arrayOf("*/*")
for type in parsedMimeTypes {
if type.isEmpty == false {
types += type
}
}
let mimeTypes = types //kotlin.arrayOf("application/pdf", "image/*")
let mimeTypes = types
isPresented.wrappedValue = false
if allowsMultipleSelection {
pickDocumentsLauncher.launch(mimeTypes)
Expand All @@ -138,36 +158,104 @@ extension View {

#else // !SKIP

fileImporter(isPresented: isPresented, allowedContentTypes: allowedContentTypes, allowsMultipleSelection: allowsMultipleSelection) { result in
fileImporter(
isPresented: isPresented,
allowedContentTypes: allowedContentTypes,
allowsMultipleSelection: allowsMultipleSelection
) { result in
switch result {
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
case .failure(let error):
// handle error
print(error)
isPresented.wrappedValue = false
}
}
#endif
}

/// Allows presenting a document exporter interface activated by the `isPresented` binding.
///
/// On iOS, this uses `fileExporter` to present the system export dialog. On Android, this uses the
/// `ACTION_CREATE_DOCUMENT` intent and copies the file to the selected location.
///
/// - Parameters:
/// - isPresented: Binding for presentation.
/// - contentType: The content type of the exported file.
/// - documentURL: The URL of the file to export.
/// - onCompletion: Called when the export finishes or fails.
@ViewBuilder public func withDocumentExporter(
isPresented: Binding<Bool>,
contentType: UTType,
documentURL: URL?,
onCompletion: ((Result<URL, any Error>) -> Void)? = nil
) -> some View {

#if SKIP
let context = LocalContext.current
let mimeType = contentType.preferredMIMEType ?? "*/*"

let exportDocumentLauncher = rememberLauncherForActivityResult(contract: ActivityResultContracts.CreateDocument(mimeType)) { uri in
isPresented.wrappedValue = false
guard let uri = uri, let documentURL = documentURL
else {
return
}

do {
guard let outputStream = context.contentResolver.openOutputStream(uri) else {
throw CocoaError.error(CocoaError.fileWriteUnknown)
}

let inputStream = java.io.FileInputStream(java.io.File(documentURL.path))
inputStream.copyTo(outputStream)
inputStream.close()
outputStream.close()
onCompletion?(.success(URL(platformValue: java.net.URI.create(uri.toString()))))
} catch {
onCompletion?(.failure(error))
}
}

return onChange(of: isPresented.wrappedValue) { oldValue, presented in
if presented == true {
isPresented.wrappedValue = false
exportDocumentLauncher.launch(documentURL?.lastPathComponent ?? "Document")
}
}

#else // !SKIP

fileExporter(
isPresented: isPresented,
document: ExportedDocument(url: documentURL),
contentType: contentType,
defaultFilename: documentURL?.lastPathComponent,
onCompletion: { result in
onCompletion?(result)
}
)
#endif
}
}

#if SKIP
private func resolvePickedDocument(uri: android.net.Uri, context: Context, uniqueDestinationName: Bool = false) -> (url: URL?, filename: String, mimeType: String?) {
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
Expand Down Expand Up @@ -215,4 +303,27 @@ private func resolvePickedDocument(uri: android.net.Uri, context: Context, uniqu
}
}
#endif

#if !SKIP
private struct ExportedDocument: FileDocument {
private static let contentTypes: [UTType] = [.data, .plainText, .commaSeparatedText, .pdf, .jpeg, .png]
static var readableContentTypes: [UTType] { contentTypes }
static var writableContentTypes: [UTType] { contentTypes }

let url: URL?

init(url: URL?) {
self.url = url
}

init(configuration: ReadConfiguration) throws {
self.url = nil
}

func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let data = try url.map { try Data(contentsOf: $0) } ?? Data()
return FileWrapper(regularFileWithContents: data)
}
}
#endif
#endif
Loading