dotnet / maui

.NET MAUI is the .NET Multi-platform App UI, a framework for building native device applications spanning mobile, tablet, and desktop.
https://dot.net/maui
MIT License
22.22k stars 1.75k forks source link

FilePicker doesn't take into account IsExternalStorageManager #6015

Open KieranDevvs opened 2 years ago

KieranDevvs commented 2 years ago

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

jfversluis commented 2 years ago

I see you mention preview 13 as the affected version. Could you please update to the latest version RC1 and try again?

ghost commented 2 years ago

Hi @KieranDevvs. We have added the "s/needs-info" label to this issue, which indicates that we have an open question for you before we can take further action. This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time.

KieranDevvs commented 2 years ago

I see you mention preview 13 as the affected version. Could you please update to the latest version RC1 and try again?

The issue is still present on RC1. The application has the correct permissions as prior to testing the RC1 Storage API, I called the Android API which worked. image

NJCCJohnWatson commented 2 years ago

Hi KieranDevvs I watched your issue of https://github.com/dotnet/maui/discussions/6004 and this,I don't get the point of you question. Is it the api said can manage all files ,but now still just copy the file of you FilePicker picked to /cache path right? If so,I have same issue.Would you might to tell me that did you fix this issue on your project?and how? Thank you

KieranDevvs commented 2 years ago

Hi KieranDevvs I watched your issue of #6004 and this,I don't get the point of you question. Is it the api said can manage all files ,but now still just copy the file of you FilePicker picked to /cache path right? If so,I have same issue.Would you might to tell me that did you fix this issue on your project?and how? Thank you

MAUI has its own helper library called essentials that provides a wrapper api around each platform. For example, without the essentials library, you would have to handle the logic of reading and writing files from persistent storage on both iOS and Android separately because they both have differing implementation details.

The Android API has changed in recent versions so that applications can no longer access files outside of their own domain however, there are some applications that need the functionality to access files outside of their application domain thus the later versions of the Android API have included the MANAGE_EXTERNAL_STORAGE permission so that they can request the user authorize this behaviour on their device.

The issue I have raised is referring to the problem that the essentials file manager API does not take into account this new permission and thus there is no way to obtain this behaviour on Android while using the essentials library.

My work around is to call the Android API explicitly (rather than calling the essentials api) as shown in the screenshots: image image

NJCCJohnWatson commented 2 years ago

Thank you for your explain about this permision issue! I'll tried to do on my Project ,Thank you again!

VincentBu commented 2 years ago

repro with vs main build(32621.322) image

KieranDevvs commented 2 years ago

repro with vs main build(32621.322) image

Can you also confirm your android app has MANAGE_EXTERNAL_STORAGE when reproducing, as the cached values are valid under scenarios where apps only have access to their app domain.

You may need to enable it via Android settings as I've had issues getting the app to request this permission automatically: image

VincentBu commented 2 years ago

yes, I have enabled it in emulator

KieranDevvs commented 2 years ago

yes, I have enabled it in emulator

Thanks.

lada1973 commented 2 years ago

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?

KieranDevvs commented 2 years ago

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

3) 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 ```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 /// /// Main feature. Return actual path for file from uri. /// /// File's uri /// Current context /// Actual path 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; } /// /// Create file in current directory with unique name /// /// File name /// Current directory /// Created file 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; } /// /// Return file path for specified uri using CacheDir /// /// Current context /// Specified uri /// Drive File absolute path 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; } /// /// Return filename for specified uri /// /// Current context /// Specified uri /// Filename 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; } /// /// Return app cache directory /// /// Current context /// Cache directory 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; } /// /// Save file from URI to destination path /// /// Current context /// File URI /// Destination path /// Task for await 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) { } } } /// /// Return data for specified uri /// /// Current context /// Current uri /// Args names /// Args values /// Data 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); } } ```

4) 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.

lada1973 commented 2 years ago

Hi Kieran, thank you a lot for rich explanation. It'll take me some time to consume it. But I beleive, that I'll manage it.

ghost commented 2 years ago

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

KieranDevvs commented 1 year ago

Try it !

var docsDirectory = Android.App.Application.Context.GetExternalFilesDir(Android.OS.Environment.DirectoryDocuments);

File.WriteAllText($"{docsDirectory.AbsoluteFile.Path}/atextfile.txt", "contents are here");

I'm not sure what your comment has to do with this issue?

Laim commented 1 year ago

Is there any update on this?

This is a massive pain at the moment.

ac-lap commented 1 year ago

@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?

KieranDevvs commented 1 year ago

@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

ac-lap commented 1 year ago

@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

Great, Thanks

mjsb212 commented 1 year ago

So this issue is Backlogged?? Any update on a real resolution? This sounds like a MAJOR HEADACHE in adopting MAUI for production with any type of app that requires extensive file system use. I'm not understanding why this issue is not a Priority? Is the workaround still the only solution?

Laim commented 1 year ago

So this issue is Backlogged?? Any update on a real resolution? This sounds like a MAJOR HEADACHE in adopting MAUI for production with any type of app that requires extensive file system use. I'm not understanding why this issue is not a Priority? Is the workaround still the only solution?

A lot of us are just dropping down an API. Instead of using API 33, use API 32 and use the old storage permissions. Still works on 33 (for now).

KieranDevvs commented 1 year ago

So this issue is Backlogged?? Any update on a real resolution? This sounds like a MAJOR HEADACHE in adopting MAUI for production with any type of app that requires extensive file system use. I'm not understanding why this issue is not a Priority? Is the workaround still the only solution?

This problem is split into two parts. 1) Android now require applciations who want to manage files, to explicitly request that permission from the user. 2) MAUI's API wrapper doesn't care if an app is given permission, it will always use the cached app domain for its storage scope.

The point im making is that even if you ditch MAUI, you still have to adopt the same process regardless of what framework / language you use. It would just be nice for the developer if MAUI can actually get its API wrappers to work properly so more boilerplate doesn't have to be written.

A lot of us are just dropping down an API. Instead of using API 33, use API 32 and use the old storage permissions. Still works on 33 (for now).

This is a restriction implemented in Android 10 (API level 29) https://developer.android.com/training/data-storage/app-specific

On devices that run Android 9 (API level 28) or lower, your app can access the app-specific files that belong to other apps, provided that your app has the appropriate storage permissions. To give users more control over their files and to limit file clutter, apps that target Android 10 (API level 29) and higher are given scoped access into external storage, or scoped storage, by default. When scoped storage is enabled, apps cannot access the app-specific directories that belong to other apps.

Zhanglirong-Winnie commented 10 months ago

Verified this issue with Visual Studio Enterprise 17.9.0 Preview 2. Can repro this issue.