maui: FilePicker doesn't take into account IsExternalStorageManager

Description

When using FilePicker with the permissions: https://developer.android.com/training/data-storage/manage-all-files, I still get a cached file copy rather than the original file path.

My use case is that I am building a document editor and need an easy way to read and write files anywhere on the device file system.

My original issue that this stems from: https://github.com/dotnet/maui/issues/4195 My work around: https://github.com/dotnet/maui/discussions/6004

Steps to Reproduce

Give your application MANAGE_EXTERNAL_STORAGE permissions, as well as special intent: ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION by going into Android settings -> Application Permissions -> Special App Access -> All Files Access -> Enable

Then open the MAUI.Essentials.FilePicker and select a file. The file path returned will be a cached copy.

Version with bug

RC1

Last version that worked well

Unknown/Other

Affected platforms

Android

Affected platform versions

Android 11 - API 30

Did you find any workaround?

I have managed to work around the issue by creating platform specific file handling, and on android, Im calling the android API directly:

var currentActivity = Platform.CurrentActivity;

// Makes sure the app has ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION before trying to read the file.
if (!Environment.IsExternalStorageManager) 
{
    var uri = Uri.Parse($"package:{Application.Context?.ApplicationInfo?.PackageName}");
    var permissionIntent = new Intent(Settings.ActionManageAppAllFilesAccessPermission, uri);
    currentActivity.StartActivity(permissionIntent);
}

var intent = new Intent(Intent.ActionOpenDocument);
intent.AddCategory(Intent.CategoryOpenable);
intent.SetType("application/json");

intent.PutExtra(DocumentsContract.ExtraInitialUri, MediaStore.Downloads.ExternalContentUri);

currentActivity.StartActivityForResult(intent, 1);

And then using the System.IO.File API to write data back to the path returned by the android file select activity.

image image

Relevant log output

No response

About this issue

  • Original URL
  • State: open
  • Created 2 years ago
  • Reactions: 6
  • Comments: 23 (1 by maintainers)

Most upvoted comments

Is there any update on this?

This is a massive pain at the moment.

Hi there, I have same problem. The filepicker returns the path to cached file. I read your conversation, but I wasn’t able to find solution. It has same behavior on the real phone. Can somebody show me, how to get it work?

Youre basically just calling the android API wrappers directly, rather than calling the essentials API. This does mean that you’ll have to write platform specific code to deal with file handling.

  1. Start an activity with the intent to select a file (this will tell the android OS to prompt the user with a native file selection window). image

  2. Have your application deal with the activity callback (this is triggered when the user has selected a file in the native file browser). Each activity has a request code / identifier so the consumer knows how to deal with the callback. (I wouldnt use this code in production but it gives you an idea on how to get it to work)

image

  1. Unfortunately the intent data isnt readable by default as it has an android specific format. I found this helper class from another project that helps you convert it to a usable file path that you can use with System.IO.
FilesHelper.cs
using Android.Content;
using Android.Database;
using Android.OS;
using Android.Provider;
using Android.Text;
using Java.IO;
using System;
using System.IO;
using System.Threading.Tasks;

namespace ResourceVault.Platforms.Android
{
    public static class FilesHelper
    {
        #region Fields

        private const string _externalStorageAuthority = "com.android.externalstorage.documents";
        private const string _downloadsAuthority = "com.android.providers.downloads.documents";
        private const string _mediaAuthority = "com.android.providers.media.documents";
        private const string _photoAuthority = "com.google.android.apps.photos.content";
        private const string _diskAuthority = "com.google.android.apps.docs.storage";
        private const string _diskLegacyAuthority = "com.google.android.apps.docs.storage.legacy";

        #endregion

        /// <summary>
        /// Main feature. Return actual path for file from uri. 
        /// </summary>
        /// <param name="uri">File's uri</param>
        /// <param name="context">Current context</param>
        /// <returns>Actual path</returns>
        public static string GetActualPathForFile(global::Android.Net.Uri uri, Context context)
        {
            bool isKitKat = Build.VERSION.SdkInt >= BuildVersionCodes.Kitkat;

            if (isKitKat && DocumentsContract.IsDocumentUri(context, uri))
            {
                // ExternalStorageProvider
                if (IsExternalStorageDocument(uri))
                {
                    string docId = DocumentsContract.GetDocumentId(uri);

                    char[] chars = { ':' };
                    string[] split = docId.Split(chars);
                    string type = split[0];

                    if ("primary".Equals(type, StringComparison.OrdinalIgnoreCase))
                        return global::Android.OS.Environment.ExternalStorageDirectory + "/" + split[1];
                }
                // Google Drive
                else if (IsDiskContentUri(uri))
                    return GetDriveFileAbsolutePath(context, uri);
                // DownloadsProvider
                else if (IsDownloadsDocument(uri))
                {
                    try
                    {
                        string id = DocumentsContract.GetDocumentId(uri);

                        if (!TextUtils.IsEmpty(id))
                        {
                            if (id.StartsWith("raw:"))
                                return id.Replace("raw:", "");

                            string[] contentUriPrefixesToTry = new string[]{
                                "content://downloads/public_downloads",
                                "content://downloads/my_downloads",
                                "content://downloads/all_downloads"
                        };

                            string path = null;

                            foreach (string contentUriPrefix in contentUriPrefixesToTry)
                            {
                                global::Android.Net.Uri contentUri = ContentUris.WithAppendedId(
                                        global::Android.Net.Uri.Parse(contentUriPrefix), long.Parse(id));

                                path = GetDataColumn(context, contentUri, null, null);

                                if (!string.IsNullOrEmpty(path))
                                    return path;
                            }

                            // path could not be retrieved using ContentResolver, therefore copy file to accessible cache using streams
                            string fileName = GetFileName(context, uri);
                            Java.IO.File cacheDir = GetDocumentCacheDir(context);
                            Java.IO.File file = GenerateFileName(fileName, cacheDir);

                            if (file != null)
                            {
                                path = file.AbsolutePath;
                                SaveFileFromUri(context, uri, path);
                            }

                            // last try
                            if (string.IsNullOrEmpty(path))
                                return global::Android.OS.Environment.ExternalStorageDirectory.ToString() + "/Download/" + GetFileName(context, uri);

                            return path;
                        }
                    }
                    catch
                    {
                        return global::Android.OS.Environment.ExternalStorageDirectory.ToString() + "/Download/" + GetFileName(context, uri);
                    }
                }
                // MediaProvider
                else if (IsMediaDocument(uri))
                {
                    string docId = DocumentsContract.GetDocumentId(uri);

                    char[] chars = { ':' };
                    string[] split = docId.Split(chars);

                    string type = split[0];

                    global::Android.Net.Uri contentUri = null;
                    if ("image".Equals(type))
                        contentUri = MediaStore.Images.Media.ExternalContentUri;
                    else if ("video".Equals(type))
                        contentUri = MediaStore.Video.Media.ExternalContentUri;
                    else if ("audio".Equals(type))
                        contentUri = MediaStore.Audio.Media.ExternalContentUri;

                    string selection = "_id=?";
                    string[] selectionArgs = new string[] { split[1] };

                    return GetDataColumn(context, contentUri, selection, selectionArgs);
                }

            }
            // MediaStore (and general)
            else if ("content".Equals(uri.Scheme, StringComparison.OrdinalIgnoreCase))
            {
                // Return the remote address
                if (IsGooglePhotosUri(uri))
                    return uri.LastPathSegment;

                // Google Disk document .legacy
                if (IsDiskLegacyContentUri(uri))
                    return GetDriveFileAbsolutePath(context, uri);
                return GetDataColumn(context, uri, null, null);
            }
            // File
            else if ("file".Equals(uri.Scheme, StringComparison.OrdinalIgnoreCase))
                return uri.Path;

            return null;
        }

        /// <summary>
        /// Create file in current directory with unique name
        /// </summary>
        /// <param name="name">File name</param>
        /// <param name="directory">Current directory</param>
        /// <returns>Created file</returns>
        public static Java.IO.File GenerateFileName(string name, Java.IO.File directory)
        {
            if (name == null) return null;

            Java.IO.File file = new Java.IO.File(directory, name);

            if (file.Exists())
            {
                string fileName = name;
                string extension = string.Empty;
                int dotIndex = name.LastIndexOf('.');
                if (dotIndex > 0)
                {
                    fileName = name.Substring(0, dotIndex);
                    extension = name.Substring(dotIndex);

                    int index = 0;

                    while (file.Exists())
                    {
                        index++;
                        name = $"{fileName}({index}){extension}";
                        file = new Java.IO.File(directory, name);
                    }
                }
            }

            try
            {
                if (!file.CreateNewFile())
                    return null;
            }
            catch (Exception ex)
            {
                return null;
            }

            return file;
        }

        /// <summary>
        /// Return file path for specified uri using CacheDir
        /// </summary>
        /// <param name="context">Current context</param>
        /// <param name="uri">Specified uri</param>
        /// <returns>Drive File absolute path</returns>
        private static string GetDriveFileAbsolutePath(Context context, global::Android.Net.Uri uri)
        {
            ICursor cursor = null;
            FileInputStream input = null;
            FileOutputStream output = null;

            try
            {
                cursor = context.ContentResolver.Query(uri, new string[] { OpenableColumns.DisplayName }, null, null, null);
                if (cursor != null && cursor.MoveToFirst())
                {
                    int column_index = cursor.GetColumnIndexOrThrow(OpenableColumns.DisplayName);
                    var fileName = cursor.GetString(column_index);

                    if (uri == null) return null;
                    ContentResolver resolver = context.ContentResolver;

                    string outputFilePath = new Java.IO.File(context.CacheDir, fileName).AbsolutePath;
                    ParcelFileDescriptor pfd = resolver.OpenFileDescriptor(uri, "r");
                    FileDescriptor fd = pfd.FileDescriptor;
                    input = new FileInputStream(fd);
                    output = new FileOutputStream(outputFilePath);
                    int read = 0;
                    byte[] bytes = new byte[4096];
                    while ((read = input.Read(bytes)) != -1)
                    {
                        output.Write(bytes, 0, read);
                    }

                    return new Java.IO.File(outputFilePath).AbsolutePath;
                }
            }
            catch (Java.IO.IOException ignored)
            {
                // nothing we can do
            }
            finally
            {
                if (cursor != null)
                    cursor.Close();

                input.Close();
                output.Close();
            }

            return string.Empty;
        }

        /// <summary>
        /// Return filename for specified uri
        /// </summary>
        /// <param name="context">Current context</param>
        /// <param name="uri">Specified uri</param>
        /// <returns>Filename</returns>
        private static string GetFileName(Context context, global::Android.Net.Uri uri)
        {
            string result = string.Empty;

            if (uri.Scheme.Equals("content"))
            {
                var cursor = context.ContentResolver.Query(uri, null, null, null, null);
                try
                {
                    if (cursor != null && cursor.MoveToFirst())
                        result = cursor.GetString(cursor.GetColumnIndex(OpenableColumns.DisplayName));
                }
                finally
                {
                    cursor.Close();
                }
            }

            if (string.IsNullOrEmpty(result))
            {
                result = uri.Path;
                int cut = result.LastIndexOf('/');

                if (cut != -1)
                    result = result.Substring(cut + 1);
            }

            return result;
        }

        /// <summary>
        /// Return app cache directory
        /// </summary>
        /// <param name="context">Current context</param>
        /// <returns>Cache directory</returns>
        private static Java.IO.File GetDocumentCacheDir(Context context)
        {
            Java.IO.File dir = new Java.IO.File(context.CacheDir, "documents");

            if (!dir.Exists())
                dir.Mkdirs();

            return dir;
        }

        /// <summary>
        /// Save file from URI to destination path
        /// </summary>
        /// <param name="context">Current context</param>
        /// <param name="uri">File URI</param>
        /// <param name="destinationPath">Destination path</param>
        /// <returns>Task for await</returns>
        private async static Task SaveFileFromUri(Context context, global::Android.Net.Uri uri, string destinationPath)
        {
            Stream stream = context.ContentResolver.OpenInputStream(uri);
            BufferedOutputStream bos = null;

            try
            {
                bos = new BufferedOutputStream(System.IO.File.OpenWrite(destinationPath));

                int bufferSize = 1024 * 4;
                byte[] buffer = new byte[bufferSize];

                while (true)
                {
                    int len = await stream.ReadAsync(buffer, 0, bufferSize);
                    if (len == 0)
                        break;
                    await bos.WriteAsync(buffer, 0, len);
                }

            }
            catch (Exception ex)
            {
                return;
            }
            finally
            {
                try
                {
                    if (stream != null) stream.Close();
                    if (bos != null) bos.Close();
                }
                catch (Exception ex)
                {
                        
                }
            }
        }

        /// <summary>
        /// Return data for specified uri
        /// </summary>
        /// <param name="context">Current context</param>
        /// <param name="uri">Current uri</param>
        /// <param name="selection">Args names</param>
        /// <param name="selectionArgs">Args values</param>
        /// <returns>Data</returns>
        private static string GetDataColumn(Context context, global::Android.Net.Uri uri, string selection, string[] selectionArgs)
        {
            ICursor cursor = null;
            string column = "_data";
            string[] projection = { column };

            try
            {
                cursor = context.ContentResolver.Query(uri, projection, selection, selectionArgs, null);
                if (cursor != null && cursor.MoveToFirst())
                {
                    int index = cursor.GetColumnIndexOrThrow(column);
                    return cursor.GetString(index);
                }
            }
            catch { }
            finally
            {
                if (cursor != null)
                    cursor.Close();
            }
            return null;
        }

        //Whether the Uri authority is ExternalStorageProvider.
        private static bool IsExternalStorageDocument(global::Android.Net.Uri uri) => _externalStorageAuthority.Equals(uri.Authority);

        //Whether the Uri authority is DownloadsProvider.
        private static bool IsDownloadsDocument(global::Android.Net.Uri uri) => _downloadsAuthority.Equals(uri.Authority);

        //Whether the Uri authority is MediaProvider.
        private static bool IsMediaDocument(global::Android.Net.Uri uri) => _mediaAuthority.Equals(uri.Authority);

        //Whether the Uri authority is Google Photos.
        private static bool IsGooglePhotosUri(global::Android.Net.Uri uri) => _photoAuthority.Equals(uri.Authority);

        //Whether the Uri authority is Google Disk.
        private static bool IsDiskContentUri(global::Android.Net.Uri uri) => _diskAuthority.Equals(uri.Authority);

        //Whether the Uri authority is Google Disk Legacy.
        private static bool IsDiskLegacyContentUri(global::Android.Net.Uri uri) => _diskLegacyAuthority.Equals(uri.Authority);
    }
}
  1. once you have the actual file path (that is not cached), you can just use System.IO.File.ReadAllTextAsync(...)/System.IO.File.WriteAllTextAsync(...), assuming that the application has the necessary android permissions to read/write to the file.

Hope that helps.

@KieranDevvs, I was trying your workaround and in step # 1 currentActivity.StartActivity(permissionIntent); I am getting this error

Android.Content.ActivityNotFoundException: 'No Activity found to handle Intent { act=android.settings.MANAGE_ALL_FILES_ACCESS_PERMISSION dat=package:com.XX.TestReader }'

I am invoking FileSelector.SelectAsync() from one of my UI pages. Can you please help with this, or if there is any sample app you may have that I can refer to?

@ac-lap Hope this helps: https://github.com/KieranDevvs/FilePickerIssueWorkaround

You can message me on discord if you have any issues: KieranDevvs#5374