apache / cordova-plugin-file

Apache Cordova File Plugin
https://cordova.apache.org/
Apache License 2.0
740 stars 757 forks source link

Cordova 12 can not save file to Android API 33 file system #613

Open Geshaa opened 6 months ago

Geshaa commented 6 months ago

Bug Report

Problem

I am using Cordova 12.0 and I am creating cordova android 12 project API 33 on which I am trying to create text file and save it on phone memory so it could be then used vie phone File Explorer. I use simple code like code below that and createFile method itselfs simple uses fileWriter write to write Blob object to file.

         function readFile(fileEntry) {

            fileEntry.file(function (file) {
                var reader = new FileReader();

                reader.onloadend = function() {
                    alert("Successful file read: " + fileEntry.fullPath + ": " + this.result);
                };

                reader.readAsText(file);

            }, fail);
        }

        function writeFile(fileEntry, dataObj) {
            fileEntry.createWriter(function (fileWriter) {

                fileWriter.onwriteend = function() {
                    alert("Successful file write...");
                    readFile(fileEntry);
                };

                fileWriter.onerror = function (e) {
                    alert("Failed file write: " + e.toString());
                };

                if (!dataObj) {
                    dataObj = new Blob(['some file data'], { type: 'text/plain' });
                }

                fileWriter.write(dataObj);
            });
        }

        function createFile(dirEntry, fileName, isAppend) {
            dirEntry.getFile(fileName, {create: true, exclusive: false}, function(fileEntry) {

                writeFile(fileEntry, null, isAppend);

            }, fail);
        }

        window.resolveLocalFileSystemURL(cordova.file.externalDataDirectory , function (dirEntry) {
            createFile(dirEntry, "fileToAppend.txt", false);
        }, fail);`

`

However I have no error but file has never been created in the device/emulator.

What is expected to happen?

File to be created somewhere in the device. Maybe I have to use different key than externalDataDirectory ?

What does actually happen?

Nothing

Environment, Platform, Device

Windows 10 running Android Studio

Version information

Cordova 12 Cordova android 12 Cordova-plugin-file 8.0.1 Android Emulator API 33

Checklist

breautek commented 6 months ago

Can you show the complete code?

The code snippet shown only the externalDataDirectory URL. We don't know what createFile does or how you're getting the FileEntry to create/write to.

Geshaa commented 6 months ago

@breautek sure, my initial post has been updated with the whole demo code for proof of concept to write and save to file.

breautek commented 6 months ago

Thanks, now we can see what you're attempting to do and at a glance everything looks right.

There are caveats with using the external storage, but as far as I understand that doesn't apply if you're attempting to write to application-specific external storage which externalDataDirectory should be.

I'm not sure when I'll find the time but I'll try copying this code/concept in my own environment to see what I can find, if I can reproduce the issue. Normally I do that kind of stuff over the weekend and the GF might be pulling me away this weekend however.

EDIT:

so it could be then used vie phone File Explorer.

Actually... I don't think the file explorer will show externalDataDirectory because that directory is suppose to be a app-private directory on external storage. If you're looking into producing a file to be shared, you'll need to use a media store API because android restricts writing shared files via the filesystem, and has limited read access via the filesystem. This is noted here.

Geshaa commented 6 months ago

You mean to use this plugin instead - https://www.npmjs.com/search?q=ecosystem%3Acordova%20storage%20access%20framework ?

@breautek maybe you can give me example code which I can try as these plugins github pages are not very informative?

Geshaa commented 6 months ago

Actually... I don't think the file explorer will show externalDataDirectory because that directory is suppose to be a app-private directory on external storage. If you're looking into producing a file to be shared, you'll need to use a media store API because android restricts writing shared files via the filesystem, and has limited read access via the filesystem. This is noted

By the way if I will use cordova.file.externalRootDirectory I get error code 9

breautek commented 6 months ago

You mean to use this plugin instead - https://www.npmjs.com/search?q=ecosystem%3Acordova%20storage%20access%20framework ?

@breautek maybe you can give me example code which I can try as these plugins github pages are not very informative?

I've never used any of these plugins myself in my own work so I don't have first-hand experience. Some community members was talking about this plugin over at https://github.com/apache/cordova/discussions/424 maybe it has some code snippets that you can get an idea how it works.

By the way if I will use cordova.file.externalRootDirectory I get error code 9

Under scoped storage rules creating new files in the root directory is forbidden. The android docs says to use the existing sub-directories for shared content, or your application data directory.

File APIs which this plugin interfaces with are not very usable for external storage management anymore since API 29 which is when scoped storage came into effect. In fact, when scoped storage was first introduced on API 29 they completely disabled file system access across the board to the external filesystem. So this plugin will not work at all on API 29 devices. Android implemented something in API 30 they called Fuse which binds the Filesystem APIs to the scoped storage model, which allows limited access to the external storage via filesystem APIs. They primarily did this to allow NDK libraries to read media content. So effectively this file plugin is best used for internal storage use only.

Android 11 or higher supports Filesystem in Userspace (FUSE), which enables the MediaProvider module to examine file operations in user space and to gate access to files based on the policy to allow, deny, or redact access. Apps in scoped storage that use FUSE get the privacy features of scoped storage and the ability to access files using a direct file path (keeping File APIs working in apps).

Unfortunately the media store API is a drastically different API and it will be quite a feat if possible to facade the media store API behind a filesystem-like API. Which is also in part the reason they implemented the Fuse system for API 30+ devices.

Android 10 enforced scoped storage rules on file accesses by MediaProvider, but not for direct file path access (for example, using File API and NDK APIs) due to the effort required in intercepting kernel calls. As a result, apps in scoped storage couldn't access files using a direct file path. This restriction impacted app developers' ability to adapt as it required substantial code changes to rewrite File API access to the MediaProvider API.

References: https://source.android.com/docs/core/storage/scoped

Geshaa commented 6 months ago

Okay, I will try with saf-mediastore plugin to see will it work. Thank you

Mr-Anonymous commented 6 months ago

I have the same issue. The file save does not throw any error and it doesn't get saved in some Android 13 devices. Did you find a solution to this @Geshaa ? What worked for you in the end?

Geshaa commented 6 months ago

Well unfortunatelly I still have no solution @Mr-Anonymous

tmishutin commented 5 months ago

I know this is still a problem with cordova-plugin-file. After 4 days digging into the problem, the only solution that works stable so far is using another plugin: https://github.com/customautosys/cordova-plugin-saf-mediastore

here is an example on how to read file:

  window.cordova.plugins.safMediastore
      .readFile(uri)
      .then(arrayBuffer => {
        const blob = new Blob([new Uint8Array(arrayBuffer)], {
          type: file.type
        });
        resolve(blob);
      })
      .catch(error => {
        console.error("safMediastore Error reading file:", error, JSON.stringify(error));
        reject(error);
    });
Geshaa commented 5 months ago

Yep @tmishutin , I was just able to make use of this plugin as well a couple of days ago.

const ReadWriteFilesDeviceMediaStore = async (stringData, fileName) => {
   const base64Data = btoa(unescape(encodeURIComponent(stringData)))
   const result = await cordova.plugins.safMediastore.writeFile({ "data": base64Data, "filename": fileName })
   return result;
}

ReadWriteFilesDeviceMediaStore('string for export', 'fileName.txt').
mvandak commented 5 months ago

We had the same problem with writing to our own directory in Android root dir (cordova.file.externalRootDirectory) starting with Android 13.

As far as I understood from the Android documentation, MANAGE_EXTERNAL_STORAGE is enough to write to that directory. This permission was implemented in one of previous Android versions. However, this permission has to be granted exclusively by user, it is not enough to be mentioned in config.xml. We managed it in our application by checking permission at app start and if not granted, app starts intent to request permission to be granted by user. This we had done already some time ago in previous version of Android.

But, starting with Android 13 we were not able to write. I realized that the cordova-file-plugin is refusing get dir request because we had have not granted all READMEDIA* permissions and FileUtils.hasReadPermission() returned false.

If I added READMEDIA* permissions to config.xml everything worked as before.

Anyway, I don't understand why filePlugin is testing if all READMEDIA* permissions are granted to allow read access and not at least one of. IMHO, this is bypassing the Android granularity. And also MANAGE_EXTERNAL_STORAGE should be taken into account when testing read/write permissions.

I propose to test in hasReadPermission and also in hasWritePermission if all files access is granted also. If it is granted, everything works without READMEDIA* permissions granted. In case, I disabled All files access in Android Special access settings, read/write was refused with Unknown error from filePlugin I suggest these methods to be modified in FileUtils as follows:

 private boolean hasReadPermission() {
      if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
          return Environment.isExternalStorageManager() // Test all files access, MANAGE_EXTERNAL_STORAGE permission
                  || PermissionHelper.hasPermission(this, Manifest.permission.READ_MEDIA_IMAGES)
                  || PermissionHelper.hasPermission(this, Manifest.permission.READ_MEDIA_VIDEO)
                  || PermissionHelper.hasPermission(this, Manifest.permission.READ_MEDIA_AUDIO);
      } else {
          return PermissionHelper.hasPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE);
      }
}

private boolean hasWritePermission() {
    // Starting with API 33, requesting WRITE_EXTERNAL_STORAGE is an auto permission rejection
    return android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
            ? Environment.isExternalStorageManager()   // Test all file access, MANAGE_EXTERNAL_STORAGE permission
            : PermissionHelper.hasPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE);
}
breautek commented 5 months ago

Anyway, I don't understand why filePlugin is testing if all READMEDIA* permissions are granted to allow read access and not at least one of. IMHO, this is bypassing the Android granularity. And also MANAGE_EXTERNAL_STORAGE should be taken into account when testing read/write permissions.

You're right. It's done this way because A) filesystem doesn't know what kind of file you're working with and B) this represents the old behaviour the best.

Android's scoped storage model does not fit well in a "Filesystem" concept and in fact there are a lot of limitations in using Filesystem APIs when interacting with the scoped storage framework. Using a media store plugin to interface with media store APIs should allow you to not request the READ_* permissions at all.

And also MANAGE_EXTERNAL_STORAGE should be taken into account when testing read/write permissions.

While this permission will allow you to access the external filesystem as a traditional filesystem, the permission is a protected permission. Which means you need to provide justification to Google make use of the permission. Google reserves the right to reject any app that they feel do not need the MANAGE_EXTERNAL_STORAGE permission. In otherwords, unless if your app's main purpose is to manage files, you likely cannot make use of that permission and deploy to the Google Play store. This is why it's not used by the file plugin.

Google has also announced they are going to be more strict on the usage of the READ_* permissions, which puts a greater emphasize on moving towards the media store API. READ_MEDIA_IMAGES, and so on requires you to be implementing an app that requires broad access to a media type (such as a gallery app)

In otherwords, I believe the file plugin is no longer going to be useful for interacting with the external storage partition and usage of the file plugin is probably going to be best reserved for interacting with internal app private storage only. I'd strongly consider looking into media store plugin instead.

mvandak commented 5 months ago

Google reserves the right to reject any app that they feel do not need the MANAGE_EXTERNAL_STORAGE permission.

Finally, Google can accept the need of this permission, or so it's our case, the app is built for internal business usage and it is not provided by Play store. Other words, application can have this permission allowed, so FilePlugin should consider allowed MANAGE_EXTERNAL_STORAGE permission.

We are storing logs, zip backups and images to specific directory in android root to be accessible for admin externally, not only from app. So it is not only media file type, but any type. I did not have enough time to look closely on SAF and Media store yet, but it seemed to me to be supposed only for media file type usage. Maybe I'm wrong.

Is there a way to use media store to acces any type of file? Is it possible to access it in any directory?

Why can't be FilePlugin used as facade to media store, so any implementation of FilePlugin do not need to be rewritten.

Thank you for your response. It saves me time.

breautek commented 5 months ago

Why can't be FilePlugin used as facade to media store, so any implementation of FilePlugin do not need to be rewritten.

Might be possible but will be very difficult and/or error prone. Media Store interfaces exposes no file structure. It's just containers so to speak that you query against. So the concept of directories, file paths, etc doesn't really exists. So it will be hard to facade that concept using a traditional filesystem API.

I did not have enough time to look closely on SAF and Media store yet, but it seemed to me to be supposed only for media file type usage. Maybe I'm wrong.

I'm pretty sure there is a misunderstanding there, but I don't interact with the external filesystem in any of my projects so I don't have first hand experience either. What I do know is filesystem access to external storage is limited to images, videos and audio files, or app's own private data (in the app's external data directory). That's why the READMEDIA* permissions only includes video, audio and images, but not documents for example.

If you're using the app's private data directory, like cordova.file.externalDataDirectory, I don't believe that restriction is imposed but I'm not 100% sure either.

If you need to access other kinds of document files like zips or txt, etc, then the MediaStore API is the only path to access non-media files. Or if you're not deploying your app through app stores then the MANAGE_EXTERNAL_STORAGE permission may be a solution in your particular case. You should be able to use to config-file to add the permission declaration. The MANAGE_EXTERNAL_STORAGE permission I believe is not requestable (e.g. you can't prompt for the permission), the user must go into the app settings and enable your app to have broad filesystem access, which is UI only shown for apps that has MANAGE_EXTERNAL_STORAGE declared in the manifest.

An example is something like:

<config-file target="AndroidManifest.xml" parent="/*" xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
</config-file>

It's untested but presumably if the permission is declared and user has the setting enabled in the app settings, then the filesystem APIs should just work... (I think). The plugin may need to be forked to remove READ_MEDIA/READEXTERNAL* checks however.

mvandak commented 5 months ago

As I had mentioned in my first comment, we did manage this to be working.

We use MANAGE_EXTERNAL_STORAGE already. At the app start we check the permission is allowed and if not, we start intent to request user to allow access to all files directly without his need to manage to go to Android settings. This is working fine.

What we had realized with Android 13 is that it was not working, because file plugin did not consider this MANAGE_EXTERNAL_STORAGE permission and did not allow getDirectory request. We then realized that we did not have mentioned READ_MEDIA_* permissions in config, so were not allowed. After adding these permissions, that is FilePlugin testing in hasReadPermission, now it is working fine. But in fact, we do not need these READ_MEDIA_* permissions. So we do not have any issues with file plugin now.

And there was my comment targeted, file plugin requires READ_MEDIA_* permissions, but they are not needed if MANAGE_EXTERNAL_STORAGE is allowed, because in that case, app has access to all files. So file plugin could take that into account.

This was maybe the root cause of problems also of other implementations that realized problems with file plugin on Android 13. That the app did not request READ_MEDIA_* permission and FilePlugin refused the access request.

FYI, we store files in cordova.file.externalRootDirectory / appName, corresponding to uri storage/emulated/0/appName if I remember, so it is the direct root of Android storage.

Mr-Anonymous commented 4 months ago

ok, I read the previous discussions here and I am still unsure on how do I resolve this for this plugin in Android 13? So what is the solution for this please? In my app, I allow users to download photos and PDFs that they can store in their device in any of these urls which is available:

                      cordova.file.documentsDirectory,
                       cordova.file.externalApplicationStorageDirectory,
                       cordova.file.externalCacheDirectory,
                       cordova.file.externalDataDirectory,
                       cordova.file.externalRootDirectory,
                       cordova.file.sharedDirectory,
                       cordova.file.syncedDataDirectory

But nothing is triggered and there are no errors showing either.

breautek commented 4 months ago

FYI, we store files in cordova.file.externalRootDirectory / appName, corresponding to uri storage/emulated/0/appName if I remember, so it is the direct root of Android storage.

Might be worth a test but I think /storage/emulatoed/0/<appName>/ is the app's external directory and is not subjected to the scoped storage restrictions... not 100% sure but that directly specifically shouldn't require any permissions at all (since API 19). I think any other external directory is subjected to the scoped storage restrictions.

sorojas-technisys commented 3 months ago

Hi,

After reviewing the previous discussions on this topic, I find myself in agreement with Mr. Anonymous. It appears that the solution remains unclear. Despite adding READMEDIA* permissions to my config.xml, my app fails to execute the desired action, specifically downloading a PDF file.

In my code, I use cordova.file.externalRootDirectory + Download (file:///storage/emulated/0/Download). While this approach functions as intended on devices running Android 11, it encounters issues on those running Android 13. Consequently, I'm left wondering whether it's feasible to download files to the specified folder.

I attempted using cordova.file.externalDataDirectory + Download, which yielded partial success. This method opens the file with a PDF viewer, facilitating the download to the Download folder. However, I observed that when using this directory, the file is located in a folder structure resembling file:///data/user/0/app/files/Download. This behavior deviates from my expectations and could potentially complicate the file retrieval process for end-users.

mvandak commented 3 months ago

Hi Sonja @sorojas-technisys , as far as I remember, starting with Android 13 the app is not allowed to write to externalRootDirectory. There are specific directories where could be files written by Media providers.

If you want to use externalRootDirectory, as I wrote before, if MANAGE_EXTERNAL_STORAGE is allowed, the app is able manage externalRootDirectory. And so all the READ_MEDIA_* have to be allowed because of FilePlugin requires it however it is not necessary from the Android point of view if MANAGE_EXTERNAL_STORAGE is allowed.

As was mentioned by breautek, Google can block app with this MANAGE_EXTERNAL_STORAGE permissions requirement if to be provided in Play store. For me this is not problem as our app is used in enterprise internally.

breautek commented 3 months ago

Hi,

After reviewing the previous discussions on this topic, I find myself in agreement with Mr. Anonymous. It appears that the solution remains unclear. Despite adding READMEDIA* permissions to my config.xml, my app fails to execute the desired action, specifically downloading a PDF file.

In my code, I use cordova.file.externalRootDirectory + Download (file:///storage/emulated/0/Download). While this approach functions as intended on devices running Android 11, it encounters issues on those running Android 13. Consequently, I'm left wondering whether it's feasible to download files to the specified folder.

I attempted using cordova.file.externalDataDirectory + Download, which yielded partial success. This method opens the file with a PDF viewer, facilitating the download to the Download folder. However, I observed that when using this directory, the file is located in a folder structure resembling file:///data/user/0/app/files/Download. This behavior deviates from my expectations and could potentially complicate the file retrieval process for end-users.

A PDF is a document file and android lacks a permission system for document files. They only have _MEDIA, _VIDEO, and _AUDIO for reads. Additionally there is no permissions for writes.

More details on this is now documented but if you're working with document files and require to use the external filesystem, then migrating to a MediaStore plugin will probably become necessary.

vikas442 commented 1 month ago

Hi @breautek ,

In my case I'm not getting callback from FileSystem's getFile funtion where it is pointing to cache directory.

globules-io commented 7 hours ago

Adding my 2c Adding this plugin breaks other plugins, in my case firebasex. As soon as I add this plugin, some issue occurs where other plugins break because they run before device ready. Removing this plugin fixes the issue.

This works

    <plugin name="cordova-plugin-device" spec="^2.1.0" />
    <plugin name="cordova-plugin-statusbar" spec="^4.0.0" />
    <plugin name="cordova-plugin-camera" spec="^7.0.0" />
    <plugin name="cordova-plugin-inappbrowser" spec="^6.0.0" />
    <plugin name="cordova-plugin-geolocation" spec="^5.0.0" />
    <plugin name="cordova-plugin-firebasex" spec="^16.5.0">
        <variable name="ANDROID_ICON_ACCENT" value="#742A84" />
        <variable name="IOS_STRIP_DEBUG" value="true" />
        <variable name="FIREBASE_ANALYTICS_COLLECTION_ENABLED" value="false" />
        <variable name="FIREBASE_PERFORMANCE_COLLECTION_ENABLED" value="false" />
        <variable name="FIREBASE_CRASHLYTICS_COLLECTION_ENABLED" value="false" />
    </plugin>

then adding this plugin breaks the app.