Skip to content

Document Picker and Document Preview#13

Merged
marcprux merged 10 commits intoskiptools:mainfrom
macshodan:main
May 29, 2025
Merged

Document Picker and Document Preview#13
marcprux merged 10 commits intoskiptools:mainfrom
macshodan:main

Conversation

@macshodan
Copy link
Contributor

added a view modifier for selecting a file (pdf, image) on the device and expose its url to the app

Document Viewer

added a view modifier for previewing a selected file (pdf, image) that's availble in the app. It could be a local downladed file or a file selected with the document picker

ReadMe

updated the readme to describe the two new view modifiers

Thank you for contributing to the Skip project! Please use this space to describe your change and add any labels (bug, enhancement, documentation, etc.) to help categorize your contribution.

Skip Pull Request Checklist:

  • [ x] REQUIRED: I have signed the Contributor Agreement
  • [ x] REQUIRED: I have tested my change locally with swift test
  • [x ] OPTIONAL: I have tested my change on an iOS simulator or device
  • [ x] OPTIONAL: I have tested my change on an Android emulator or device

added a view modifier for selecting a file (pdf, image) on the device and expose its url to the app

Document Viewer

added a view modifier for previewing a selected file (pdf, image) that's availble in the app. It could be a local downladed file or a file selected with the document picker

ReadMe

updated the readme to describe the two new view modifiers
@cla-bot cla-bot bot added the cla-signed label May 27, 2025
Fixed a variable name after a refactoring
Copy link
Member

@marcprux marcprux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! Just a couple small changes and one question in the review.


let sourceURL = URL(string: uri.toString())!
let inputStream = resolver.openInputStream(uri)!
let bufferedInputStream = java.io.BufferedInputStream(inputStream)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can all be simplified with inputStream.copyTo(outputStream). See https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.io/copy-to.html

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even simpler, you could use File.copyTo(File, overwrite = true)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! I'm going to to try it out.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I've tested both methods, File.copyTo(File, overwrite = true) is not working, I guess because the uri returned from Activity result is not directly readable and creating a file from that returns a file instance that's not usable.

I've simplified the code using your first suggestion, removing the loop and using the direct copy on the input stream.


return onChange(of: isPresented.wrappedValue) { oldValue, presented in
if presented == true {
let mimeTypes = kotlin.arrayOf("application/pdf", "image/*")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to limit this to PDF and images? Could we instead have another parameter to withDocumentPicker that is a set of mime types that can be accepted (or empty to accept all files)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the thing is that Android and iOS uses different way to specify mime types. On iOS is an array of UTType, while on Android is an array of strings. Do you think the better approach is to use two different parameters?

Copy link
Member

@marcprux marcprux May 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the best thing might be to create a stub of UTType for the Android side with just enough of the API to get it to build. Like:

#if SKIP
public struct UTType: Equatable, Hashable {
    var identifier: String
    var preferredMIMEType: String?

    public init?(mimeType: String, conformingTo supertype: UTType = .data) {
        self.identifier = identifier
        self. preferredMIMEType = preferredMIMEType
    }
}

public extension UTType {
    public static let data = UTType(mimeType: "data/…")
    // ... other common extensions as needed
}

#endif

then your function can accept a set of UTTypes on both sides, and on Android you will just grab . preferredMIMEType and use that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks I'll work on that!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I've tried to follow your suggestion, but I'm not able to make it work. I've created a UTType stub like that:

public struct UTType: Equatable, Hashable {
    var identifier: String
    var preferredMIMEType: String?
    
    public init?(mimeType: String, conformingTo supertype: UTType? = .data) {
            self.identifier = mimeType
            self.preferredMIMEType = mimeType
        }
}

public extension UTType {
    public static let data = UTType(mimeType: "data/...")
    public static let pdf = UTType(mimeType: "application/pdf")
    public static let image = UTType(mimeType: "image/*")
    public static let text = UTType(mimeType: "text/*")
    public static let doc = UTType(mimeType: "application/msword")
}

and added inside the #if SKIP compile directive. Then I've changed the viewBuilder function signature like that:

 @ViewBuilder public func withDocumentPicker(isPresented: Binding<Bool>, allowedContentTypes: [UTType], selectedDocumentURL: Binding<URL?>, selectedFilename: Binding<String?>, selectedFileMimeType: Binding<String?> ) -> some View {

to make it compile correctly I also had to include under the #if !SKIP block the following import

#import UniformTypeIdentifiers

Doing that I've been able to comipile, but when I try to use it in a project I'm facing the same problem again, for example when using it like that:

.withDocumentPicker(isPresented: $isPresented, allowedContentTypes: [.image, .pdf], selectedDocumentURL: $documentURL, selectedFilename: $selectedFileName, selectedFileMimeType: $selectedFileType)

I have the following error:

Argument type mismatch: actual type is 'skip.lib.Array<Element>', but 'skip.lib.Array<skip.kit.UTType>' was expected.

which brings me in a sort of a loop. I can't use the skip UTType I've created, because it has no ties with the original type, but on the other hand the transpiler don't know how to use it.

Do you think I'm doing something wrong?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, If I don't add the import it still says that it can't find UTType in scope and suggest to import UniformTypeIdentifiers, that I have to import in the #if !SKIP block.

After that I get the following error: Argument type mismatch: actual type is 'skip.lib.Array<Element>', but 'skip.lib.Array<skip.kit.UTType>' was expected. when compiling.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you commit and push what you have so I can see the error? It seems like it should work.

For the UniformTypeIdentifiers header issue, I think SkipKit.swift could have:

#if !SKIP
@_exported import UniformTypeIdentifiers
#endif

and then anyone importing SkipKit will be able to use the UTType.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi!

I've just pushed the latest modifications. I've added the import in the SkipKit.swift which allows me to remove the import from the DocumentPicker.swift file.
The Package compile just fine, you can see the problem when trying to use it in a project.

Here a simple View I used in a project to test it:

import SwiftUI
import SkipKit

struct DocumentPickerTestView: View {
    @State private var isPresented: Bool = false
    @State private var documentURL: URL?
    @State private var selectedFileName: String?
    @State private var selectedFileType: String?
    @State private var showPreview: Bool = false
    
    @State private var takePicture: Bool = false
    @State private var pictureURL: URL?
    
    var body: some View {
        VStack(spacing: 32) {
            Spacer()
            
            Button("Pick Document") {
                isPresented = true
            }
            .withDocumentPicker(isPresented: $isPresented, allowedContentTypes: [UTType.image, UTType.pdf], selectedDocumentURL: $documentURL, selectedFilename: $selectedFileName, selectedFileMimeType: $selectedFileType)
                        
            if let documentURL {
                VStack {
                    if let selectedFileName {
                        Text(selectedFileName)
                            .font(.caption)
                            .multilineTextAlignment(.center)
                    } else {
                        Text("\(documentURL.absoluteString)")
                            .font(.caption)
                            .multilineTextAlignment(.center)
                    }
                    
                    Button {
                        showPreview = true
                    } label: {
                        Text("Mostra preview")
                    }
                    .withDocumentPreview(isPresented: $showPreview, documentURL: documentURL, filename: selectedFileName, type: selectedFileType)
                }
            } else {
                Text("nessun documento selezionato")
            }
            
            Spacer()
        }
    }
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message was misleading, but the issue is that the UTType static constants were Optional<UTType> because that init is optional. Force-unwrapping them fixed the issue.

I've move it out into its own separate UTType.swift file added some more constants. Once CI passes, I'll merge the PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!

Thanks!

/// - isPresented: binding della variabile di stato che indica se il compoemente è visualizzato o meno
/// - documentURL: URL del file che si intente visualizzare
/// - Returns: su iOS un QLPreviewController mentre su Android viene lanciato in Intent ACTION_VIEW per la scelta dell'App da utilizzare.
@ViewBuilder public func withDocumentPreview(isPresented: Binding<Bool>, documentURL: URL?, filename: String? = nil, type: String? = nil) -> some View {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to add // SKIP @nobridge to prevent bridging until we can implement support for view extensions. Like at https://github.com/skiptools/skip-kit/blob/main/Sources/SkipKit/MediaPicker.swift#L48C5-L48C22

macshodan added 3 commits May 27, 2025 19:52
simplified the implementation of cache copyiing using the BufferedInputStream copyTo method

added skip nobridge
Added output and input close at the end of the file copy as stated in : https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.io/copy-to.html
fixed a bug that when using for the first time after showing the permission request dialog on Andoroid users wont be able to tap on the button to activate the picker unless they reload the view, since the `isPresented` flag was still set to true.
filename.wrappedValue = name
mimeType.wrappedValue = type

// To be able to access the file from another part of the app it needs to be copied in tha cached directory:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading a little more about this, could you call context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) to get persistent access to the URI, and thereby avoid needing to perform the copy operation? This would be preferable, since it would also allow a user to edit the file in the same way on both platforms, and also prevents the buildup of cached copies of selected files over time when they might not be needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi,
I've tried your suggestion but I can't make it work. Getting the permissions in fact seems to make the uri readable, but the problem is that it's still a content:// type uri that can't be accessed without a content resolver. Doing so it means that the user will need to implement a different logic to handle the file on Android when it's returned, because on iOS it will be a directly accessible file while on Android it stil need to rely on a content resolver.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right, I forgot it would be a content://. OK, let's stick with copying the file.

macshodan and others added 5 commits May 29, 2025 09:12
Created a UTType stub for Android for letting user choose what kind of files select with the document picker.

Note: it compiles fine, but when using the view extension `.withDocumentPicker` in an actual project it gives a compilation error `Argument type mismatch: actual type is 'skip.lib.Array<Element>', but 'skip.lib.Array<skip.kit.UTType>'`
@marcprux marcprux merged commit 680c265 into skiptools:main May 29, 2025
2 checks passed
@marcprux
Copy link
Member

This will be a great feature to have! Thanks very much for the contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants