apache / cordova-plugin-file-transfer

Apache Cordova File Transfer Plugin
https://cordova.apache.org/
Apache License 2.0
595 stars 888 forks source link

Getting code 1 file exists on file download #367

Open rolinger opened 10 months ago

rolinger commented 10 months ago

Plugin version 2.0.0-7

On Android, downloading files - everything was working and then I changed a bunch of variable and function calls to clean up the code and now its not working. Obviously I changed something but at the moment can't figure out what. A print out of the error code gives me this:

body: null
code: 1
exception: "/storage/emulated/0/Download/MyApp/BYG/REIA_Check_List.doc: open failed: EEXIST (File exists)"
http_status: 200
source: "https://portal.mydomain.com/files/C1002/docs/reia-doc_8125235.doc?cb=1693485905486"
target: "file:///storage/emulated/0/Download/MyApp/BYG/REIA_Check_List.doc"

Its the correct source that I can download with the same direct url in a browser. The target is the correct location and file name...and this is the same place it was writing to prior to code changes and breaking. The error implies the file already exists but checking in the folder it does not exist, the path MyApp/BYG/ does exist

What does this error mean: Code 1, open failed: EEXIST (File exists) ??

From reading other issues, Code 1 just seems to be a general error code and not specific to the actual problem. I also saw code 1 with a permission denied messages, so Code 1 just seems to mean a general failure

rolinger commented 10 months ago

I figured out what is going on. If your app downloads the file and then the user manually deletes the file you cannot save the file with the same name again; this is related to some Android caching of some sort. I found this reference here:

https://github.com/itinance/react-native-fs/issues/1078 https://learn.microsoft.com/en-us/answers/questions/932579/after-manually-delete-a-file-and-use-fileoutputstr

Apparently this started in Android 11+. The proper solutions offered haven't worked for me. But the hack solution does work.

If the user manually deleted the file then use the error to "EEXIST" to determine that, then redownload the file again and save it with a slightly different name...add a random character to the end of the name and it will save.

rolinger commented 10 months ago

Whats crazy about this issue is after manually deleting the file with the phones File Manager, my app reads the directory and doesn't see the file. But when writing the file, with the same previous file name, it can't do it because Android says the file is still there. So why is one saying its not there and the other is?

breautek commented 10 months ago

It sounds like you're experiencing scoped storage rules, which is effective since API 29. I wrote a blip in a PR for the file plugin but the same would apply here.

Basically scoped storage applies to all external storage partitions (/storage/emulated/0/ is considered an external storage partition, which is used to emulate external storage when a physical medium is not available).

With scoped storage, applications do not require permission to read or write files, however it can only read and write files that the application itself has written to. So for example, if another app (Say the chrome browser) downloads a file and stores it at: /storage/emulated/0/Download/MyApp/BYG/REIA_Check_List.doc, that file will not be read or writeable by your app since the file is not owned by your app.

Listing directories will not show files that your app doesn't have access to. However attempting to write to a filename that already exists will be blocked.

There is limited capability of reading files owned by other apps with READ_EXTERNAL_STORAGE permission on pre-API 33 devices, and READ_MEDIA_AUDIO, READ_MEDIA_VIDEO, and READ_MEDIA_IMAGES for API 33+ devices. If you're observant you'll notice that there are only permissions for multimedia assets, but not nothing for document files. This is because scoped storage only allows reading media files. So reading your .doc file is not possible via a file API. Additionally, there are no write permissions for writing to files owned by other apps. You cannot modify a file owned by another app via the file api.

The file transfer plugin uses the file plugin behind the scenes, which naturally uses the file apis.

So how do work with non-media files?

Well the native way is to use a non-file API, and use something called a MediaStore. Apache doesn't have a plugin that interfaces with this MediaStore outside of context specific plugins (like the camera plugin which deals with images/video). But there are third-party plugins available that interfaces with the media store with a generic API: https://www.npmjs.com/search?q=ecosystem%3Acordova%20storage%20access%20framework

If placing these files in external storage is a requirement, then you'll probably need to use the file transfer plugin to download into your internal cache directory (cordova.file.cacheDirectory), then use a media store plugin to transfer from the internal cache directory to media store.

Lastly if you support API 29, then using MediaStore APIs is a requirement because Android lacks a filesystem API into scoped storage, which was only introduced in API 30. So accessing any external storage mechanism on API 29 is blocked.

If you think any of this sounds bizarre, then you're not alone. You can read learn more about scoped storage in the android docs

rolinger commented 10 months ago

@breautek - As always Norman, you give very thorough explanations and I REALLY appreciate your responses and the time you put into your responses. Android needs to hire you to rewrite their docs because they suck.

Based on what you wrote above about reading scoped files I have an additional question. If using this file-transfer plugin I am able to download the files. My app doesn't need to modify/edit change the docs, just download so the user can reference/read them. If my app downloads thisFile.pdf to my own MyApp/Sub-Folder/thisFile.pdf location, can my app open that file into another app PDF viewer - it should still work yes? And if the user navigates to the file via their phones File Manager they should be able to open it in what ever app they choose correct?

So long as users can download files from my app and open them other apps without issue I am going to continue to use this plugin. It keeps it simple and clean. All I have to do now add code to download the file again, changing the name slightly, if the FileTransfer gets the error with EEXIST in it.

breautek commented 10 months ago

If my app downloads thisFile.pdf to my own MyApp/Sub-Folder/thisFile.pdf location, can my app open that file into another app PDF viewer - it should still work yes?

This part I'm not 100% sure, but I don't think it will work out of the box. The part that makes it more difficult is the fact that your file are document files, not a media file. (Media as in an image, video or audio).

Normally if you had a image file owned by App A. App B could read it without explicit permission from App A if App B has READ_EXTERNAL_STORAGE or READ_MEDIA_IMAGE permission granted.

However because you're working with document files, that convenience escape hatch is not available. In order to share document files with other apps, App A needs to implement something called a ContentProvider which can accept and grant read (or write) permission to App B.

I could be completely wrong here.... but I believe the typical flow in that kind of use case is that:

  1. App B uses a file picker intent to open up a document tree where they may pick your App A's thisFile.pdf.
  2. This will will trigger a ContentProvider request implemented by App A where
  3. App A can decide to grant temporary permission to App B.
  4. App B can then read the document file to use.

Cordova doesn't implement any content providers which is why I don't think it will just work. And I think implementing one will be difficult since the details of a content provider will be largely app-specific.

I don't have a complete understanding on the content provider concepts in Android, it's not something I have actually dabbled with. But it is the mechanism for sharing content between apps, from what I can understand.

rolinger commented 10 months ago

@breautek - well....good news. Changing the name allows for redownload of file. And after downloading, navigating to folder with phones general file manager allowed me to open the file with another app. However, the other app did require permissions to access the file; it opens dialog "Go To Settings", which then opens to "All Files Access" forcing the user to allow access for the app they choose to view the file with. Tried it on several different app types and it was the same for all of them.

The equally good news is that the other app asks for the permissions, so I don't have to code in my app giving permissions. I don't want the user viewing files in my app anyway...too many issues rendering different file formats in my app - just easier to let native apps do it.

function priFileDL(fInfo){
  startStatus(fInfo.cfID,"black","Starting download...") ;

  var fileTransfer = new FileTransfer();
  var folderPath=fInfo.dir+fInfo.cfSave ;

  var onSuccess= function(e){
    fileDone(fInfo,e) ;
    if (ionic.Platform.isIOS()) {
      fileMove(folderPath) ;
    }
  };

  var onError=function(e) {
    // this handle issue if user had downloaded file before
    // and then manually deleted it from a file manager
    // this appends the name, making it a unique download
    if (e.exception.includes("EEXIST")) {
      var n = fInfo.cfSave.split(".") ;
      n[0] += "1" ;
      fInfo.cfSave = n.join(".") ;
      priFileDL(fInfo) ;
    } else {
      fileError(1,e,fInfo) ;
    }
  };

  fileTransfer.onprogress = function(pe) {
    fileProgress(pe,fInfo) ;
  }

  fileTransfer.download(fInfo.url,folderPath,onSuccess,onError);
}  
rolinger commented 10 months ago

@breautek - btw...all of this was on Android. I haven't tested iOS yet. The other issue now is how a user, from within my app, can click a button that then opens the file in another app using some kind of app picker. Trying cordova-plugin-file-opener2 as a first stab at it, but so far no luck.

rolinger commented 10 months ago

@breautek - final update on this one. On Android, I got everything working on compiledSDK/targetSDK 33.

  1. Download file to custom /MyApp/sub-folder1/ path in the users standard /Download folder - IE: /Download/MyApp/sub-folder1/thisFile.pdf
  2. Got around issue if user manually deleted file from /Download/MyApp/sub-folder1/ by triggering off of native Android error of EEXIST. If that error is detected, then redownload the same file and save to target with a different name (add a character 1) to the end of the filename. This gets around the Android scoping issue without having to get into complex user permissions or related issues.
  3. Finally, using cordova-plugin-file-opener2, I can display an open file button in my app that will auto-launch a app picker for the user to select which app to use to open the file with. In THOSE apps, they ask for permission access to files downloaded by my app. The user needs to authorize my access to my app files and all is good. The same process happens if the user navigates their devices File Manager to my app folders and tries to open a file - they select the app, and if permissions not yet granted those apps will walk user through granting access.

An update, this worked with only the following permissions set in my config.xml

            <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
            <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
            <application android:requestLegacyExternalStorage="true" />

Now...just need to validate all this on iOS.