Native iOS/Android file choosers

I’m surprised you can’t read it. You are using the URL results, right? I have a test app for opening and reading images and it works fine with iCloud and Dropbox. How are you opening the stream? I’m using URL::createInputStream.

Hi, no - these are showing up as local files (by testing) so I’m wrapping them in a File object.

I’ll try using the URL results instead…

After testing this again, the URLs returned are also local files on my test phone. Maybe only getting the file size is an issue? Internally URL::createInputStream will simply return a FileInputStream if the URL is a local file, so reading might still work (well it definitely works for me).

Trying to parse the XML document also fails with an error indicating there is not enough data (can’t remember the exact message). Let me try the URL stuff later. Using a File was the easiest way to test as use can construct an XMLDocument from a File.

Hi, not having any luck opening a stream either, e.g, the following URLs returns a nullptr, one each from iCloud, Dropbox and Google. The call I’m using to open the stream is from the NetworkDemo.

url: file:///private/var/mobile/Library/Mobile%20Documents/com~apple~CloudDocs/Circuit%20Sysex/circuit_init.xml
url: file:///private/var/mobile/Containers/Shared/AppGroup/26F73D62-049B-40CB-A823-1CE8A33B96D0/File%20Provider%20Storage/11425806/local-storage/L2NpcmN1aXQvc3lzZXgvY2lyY3VpdF9pbml0LnhtbA==/circuit_init.xml
url: file:///private/var/mobile/Containers/Shared/AppGroup/2C55BD79-B8EC-459C-9E0E-E43D97A48697/File%20Provider%20Storage/18039015/1ckBRynrRhK7ZeICIou_g8lD7eN2Q8yIi/bbb.xml

Hmmm, I just can’t reproduce this problem you are having. Can you try the following? Can you add the following line to juce_gui_basics/native/juce_ios_FileChooser.mm:149:

[url startAccessingSecurityScopedResource];

Edit: it might also be worth putting a breakpoint there and seeing what it returns

Hi, I’ve added that line and I can now read from iCloud and Dropbox :D, but not GoogleDrive.

Reading on Android looks good, from local and googledrive anyway. Not found a way of integrating Dropbox so far, so maybe this can’t be done on Android.

For writing on Android the following observations:

  1. Everything apart from my Downloads folder appears as remote so I need to use a URL to write the data it appears, though I’m confused how this fits in with the new fileToWrite parameter on launchAsync as this is limited to local files.
  2. Even though I can “successfully” write a local file, they always end up as 0 bytes (maybe that’s my end, will check further)
  3. If I open the browser with “*.xml” and the user types a filename in, I’d expect the .xml to be applied to the file as it does on desktop, but this doesn’t seem to be happening.

Edit: 2. this seems to be an oddity of writing to the downloads directory, i’ve checked in my code that the file written was the same size as the temp file and this passed - however, in the browser the file always shows up as 0 bytes.

Great stuff ! I want more of these goodies !

On Android, the URLs typically start with content:// but you can still convert them with URL::getLocalFile to a local file pointer. In fact, simply using FileChooser::getResults should do the conversion for you.

1 Like

ok, thx will give that a go.

I guess this is a bit confusing, when loading files I should use getURLResult() to process a URL, when saving I should use getResult() to get a local File pointer. I assumed I should use a matching pair - maybe this needs to be documented, or add some concrete examples for actually reading/writing files. The demo program uses getURLResult() for both load and save and stops at printing the filename rather than doing anything with it…

I’m getting this assertion in FileChooser::finished() so maybe the isLocalFile() check needs updating to handle the conversion too?

 // The user either selected multiple files or wants to save the file to a URL
 // Both are not supported
 jassert (results.size() == 1 && results.getReference (0).isLocalFile());

No it will. There are some content:// URLs which actually need to access the network - so isLocalFile() will return false for those content:// URLs. You can use them with URL::createInputStream but I didn’t realise that they are also needed when saving. For me, when saving, I always got a local file. I’ll add a fix for this.

1 Like

You should always use the URL results if you can. In fact, we will probably deprecate the normal getResults functions. For reading files you can always use URL::createInputStream - no matter if it’s a file, a content URL or a real network URL. Thanks to your feedback, I’m realising that writing is not always a local file. I’ll need to add a URL::createOutputStream to JUCE which uses URL::withFileToUpload for network urls, some android native code for content urls and and a file output stream for local file urls.

1 Like

Lovely. I did look for the createOutputStream() method and saw there wasn’t one so started looking for other solutions. That’ll certainly balance things out and as you say, remove the need to use getResults()

Hi @leehu, I now have a tested solution on a branch which uses the createOutputStream approach. Basically, the following will work on all platforms for opening and reading a file:

fc = new FileChooser ("Choose an image to open...",
                      File::getCurrentWorkingDirectory(),
                      "*.jpg;*.jpeg;*.png;*.gif",
                      true);

fc->launchAsync (FileBrowserComponent::openMode | FileBrowserComponent::canSelectFiles,
                 [this] (const FileChooser& chooser)
                 {
                     auto results = chooser.getURLResults();
                     if (results.size() > 0)
                     {
                         auto url = results.getReference (0);

                         ScopedPointer<InputStream> wi (url.createInputStream (false));
                         if (wi != nullptr)
                         {
                             imageComponent.setImage (PNGImageFormat().decodeImage (*wi),
                                                      RectanglePlacement::onlyReduceInSize);
                         }
                     }
                 });

and the following will always work on all platforms when saving files:

fc = new FileChooser ("Where do you want to save this text file?",
                      File::getCurrentWorkingDirectory(),
                      "*.txt",
                      true);

fc->launchAsync (FileBrowserComponent::saveMode,
                 [this] (const FileChooser& chooser)
                 {
                     auto results = chooser.getURLResults();
                     if (results.size() > 0)
                     {
                         auto url = results.getReference (0);
                         
                         ScopedPointer<OutputStream> wo (url.createOutputStream());
                         if (wo != nullptr)
                         {
                             auto success = wo->writeString ("Hello World!");
                             jassert (success);
                         }
                     }
                 });

This is not on develop just yet. Lukasz will give it a code review and be in touch.

3 Likes

thx for sorting this out before heading off! will wait to here from Lukasz. enjoy your break

Hi @leehu,

Thanks for your patience! I have pushed Fabian’s updates + additional fixes to file choosers into develop here

I ended up removing fileWhichShouldBeSaved parameter. Here is the rationale:

  • it was introduced to fix issue with writing to Dropbox etc. The actual issue was though that we didn’t use correct permissions on iOS to write to and read from files from outside of the app sandbox. Creating a temporary file was only masking the issue.
  • it was only really used on iOS and it was causing assertions on Android in JUCE Demo when trying to save into Google Drive. Any network URLs (which can be returned by any OS in future) would not work.
  • it introduced additional complexity with extra param in multiple functions (that would be ignored by most of the OSes anyway) and it made the behaviour not consistent/symmetric between OSes
  • while it is up to you to move the file after the chooser returns (or calls your async callback in async version), it gives you bigger flexibility - you may decide in your app for some reason that you want a user to select multiple files over time and only write to them in bulk later, or you may decide to cancel writing/reading for other reason

I have also updated the API docs. In particular notice the following:

        @param initialFileOrDirectory         the file or directory that should be selected
                                              when the dialog box opens. If this parameter is
                                              set to File(), a sensible default directory will
                                              be used instead.

                                              Note: on iOS when saving a file, a user will not
                                              be able to change a file name, so it may be a good
                                              idea to include at least a valid file name in
                                              initialFileOrDirectory. When no filename is found,
                                              "Untitled" will be used.

                                              Also, if you pass an already existing file on iOS,
                                              that file will be automatically copied to the
                                              destination chosen by user and if it can be previewed,
                                              its preview will be presented in the dialog too. You
                                              will still be able to write into this file copy, since
                                              its URL will be returned by getURLResult(). This can be
                                              useful when you want to save e.g. an image, so that
                                              you can pass a (temporary) file with low quality
                                              preview and after the user picks the destination,
                                              you can write a high quality image into the copied
                                              file. If you create such a temporary file, you need
                                              to delete it yourself, when it is not needed anymore.

So if you want to benefit from a file preview and if you are okay with iOS auto-copying your file to a user-selected destination, you can pass a path to an already created temporary file. If not, you can at least specify a valid file name (note that the path must start with “/“), so you an e.g. set this param to “/MyFile.png” and the file name will be MyFile.png rather than Untitled.png.

A couple of examples for a callback from FileChooser:

  • if you have a temporary file, you can write its content to a returned URL directly. If a user picked a network URL (for instance Google Drive on Android), the file will be uploaded automatically. This is done in JUCE demo for instance.
fc = new FileChooser ("Choose a file to save...",
                                      File::getCurrentWorkingDirectory().getChildFile (fileToSave.getFileName()),
                                      "*",);

fc->launchAsync (FileBrowserComponent::saveMode | FileBrowserComponent::canSelectFiles,
                                 [fileToSave] (const FileChooser& chooser)
                                 {
                                     auto result = chooser.getURLResult();

                                     if (! result.isEmpty())
                                     {
                                         ScopedPointer<InputStream> wi (fileToSave.createInputStream());
                                         ScopedPointer<OutputStream> wo (result.createOutputStream());

                                         if (wi != nullptr && wo != nullptr)
                                         {
                                             auto numWritten = wo->writeFromInputStream (*wi, -1);
                                             jassert (numWritten > 0);
                                         }
                                     }
                                 });
  • here is an example where you save the file with its original content, then you append some content to it and re-read it to verify (probably not the most useful scenario, it’s just for the sake of an example):
File fileToSave = File::createTempFile (“tempDirectory”);

if (fileToSave.createDirectory().wasOk())
{
    fileToSave = fileToSave.getChildFile (“MyFile.txt");
    fileToSave.replaceWithText ("Hello! This is the original text.“);
}

fc = new FileChooser ("Where do you want to save this text file?",
                                      fileToSave,
                                      “*.txt”);
                
fc->launchAsync (FileBrowserComponent::saveMode,
                                 [this] (const FileChooser& chooser)
                                 {
                                     auto results = chooser.getURLResults();

                                     if (results.size() > 0)
                                     {
                                         auto url = results.getReference (0);

                                         {
                                             ScopedPointer<InputStream> wi (url.createInputStream (false));
                                             if (wi != nullptr)
                                             {
                                                 auto text = wi->readEntireStreamAsString();
                                                 DBG (“Original text = " + text);
                                             }
                                         }

                                         {
                                             ScopedPointer<OutputStream> wo (url.createOutputStream());
                                             if (wo != nullptr)
                                             {
                                                 auto success = wo->writeString (“\nAnd here is my appended text!”);
                                                 jassert (success);
                                             }
                                         }

                                         {
                                             ScopedPointer<InputStream> wi (url.createInputStream (false));
                                             if (wi != nullptr)
                                             {
                                                 auto text = wi->readEntireStreamAsString();
                                                 DBG (“Updated text = " + text);
                                             }
                                         }
                                     }
                                 });

I’m super curious to know if it works now for you!

1 Like

Hi Lucasz, thx for the updates. I agree with the removal of fileWhichShouldBeSaved parameter - it never quite felt right.

I should get chance to give iOS a go this evening and Android tomorrow. Cheers

1 Like

Hi, not good on iOS from my end.

I can write a file to iCloud drive. Same issue as always trying to write to Dropbox:

image

Whilst writing to GoogleDrive appears to complete with no errors, nothing appears on the drive.

For reading, I get the following error when trying to read from a URL returned by the browser:

Sorry it’s not better news :frowning: