If you've ever developed for Android, be it native with Java/Kotlin or using a cross-platform framework like Xamarin, you may have noticed the process for getting a file from the user storage (an image from the Gallery in this case) is a bit complicated. You need to launch an Intent
, then await its Result
and handle any ClipData
associated with it.
Fortunately for us Xamarin developers, the Xamarin team and the community have created the Xamarin Essentials library and one of its utilities is the MediaPicker, which abstracts from you the logic for taking a picture with the camera or getting an image from the storage. You can read the documentation for the Media picker here.
Unfortunately, the MediaPicker from the Xamarin Essentials only supports to take or load one image, so if you need to get multiple pictures you need to roll your own. In this post, I'll show you how do it.
Configuring permissions needed to load files from storage
In order to access the user's storage, you need to tell Android you need the READ_EXTERNAL_STORAGE permission. In Xamarin.Android, there are two ways to do so:
1) By adding an attribute in the Android project's AssemblyInfo.cs
[assembly: UsesPermission(Android.Manifest.Permission.ReadExternalStorage)]
2) By adding the request in the AndroidManifest.xml
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
Adding classes for the PhoneMediaPicker service
In order to open the file picker, we need to create an interface in our shared project so we can use Dependency Injection and run the corresponding implementation according to the runtime platform.
public interface IPhoneMediaPicker
{
Task<IEnumerable<MediaFile>> PickPhotosAsync(string intentTitle);
}
Then we create an implementation of this interface in our Android project:
public class PhoneMediaPicker : IPhoneMediaPicker
{
}
And the class we'll be using as our model containing the image:
public class MediaFile
{
public string MimeType { get; set; }
public Stream FileStream { get; set; }
}
Implementing the IPhoneMediaPicker
Now, it's time to roll up our shirt's sleeves and getting our hands dirty and start coding the implementation.
Ensure permission and launch intent
Even though we configured the READ_EXTERNAL_STORAGE permission, since Android 6 (API Level 23) you need the user to approve the permission at runtime. Thus, before launching the intent we're gonna use the Xamarin.Essentials Permissions API to show the request.
We're gonna need a constant to know the request is coming from our service and since our service is going to launch an intent and it request will be fulfilled in another thread, we need a way to hold on to the async task while the process is complete. For this, we are going to use a TaskCompletionSource.
Then we are going to add a method for handling the ActivityResult from the MainActivity.
Then, the code in our IPhoneMediaService should be like this.
public class PhoneMediaPicker : IPhoneMediaPicker
{
/// We use this constant on the MainActivity to know the intent was launched by our MediaPicker
private const int MediaPickerRequest = 2001;
private TaskCompletionSource<IEnumerable<MediaFile>> _completionSource;
public async Task<IEnumerable<MediaFile>> PickPhotosAsync(string title)
{
var results = new List<MediaFile>();
var permissionStatus = await Permissions.RequestAsync<Permissions.StorageRead>();
if (permissionStatus == PermissionStatus.Denied)
{
return results;
}
var intent = new Intent(Intent.ActionPick);
intent.PutExtra(Intent.ExtraAllowMultiple, true);
intent.SetType("image/*");
string pickerTitle = string.IsNullOrWhiteSpace(title) ? "Select pictures" : title;
var pickerIntent = Intent.CreateChooser(intent, title);
try
{
var intentChooser = Intent.CreateChooser(pickerIntent, pickerTitle);
_completionSource = new TaskCompletionSource<IEnumerable<MediaFile>>();
var mainActivity = Platform.CurrentActivity as MainActivity;
mainActivity.ActivityResult += OnActivityResult;
mainActivity.StartActivityForResult(intentChooser, RequestMediaPicker);
return await _completionSource.Task;
}
catch (Exception ex)
{
return results;
}
}
public static void OnActivityResult(Result resultCode, Intent data)
{
}
}
Get Intent result in MainActivity
As we are going to use the MainActivity for handling the intent result, we need to override the OnActivityResult method there and call this event that were are going to subscribe to in the PhoneMediaPicker.
public event Action<int, Result, Intent> ActivityResult;
protected override void OnActivityResult(int requestCode, Result resultCode, Intent data)
{
base.OnActivityResult(requestCode, resultCode, data);
if (ActivityResult != null)
{
ActivityResult(requestCode,resultCode, data);
}
}
Reading ClipData and Uri from the Intent
Once we get the result from the Intent, we need to iterate over the ClipData
that comes from the Intent (if there are multiple images selected) or load the file from the Uri
in the Data
field from the Intent.
private void OnActivityResult(int requestCode, Result resultCode, Intent data)
{
if (data != null)
{
var fileResults = new Collection<MediaFile>();
if (resultCode == Result.Ok)
{
ClipData clipData = data.ClipData;
if (clipData != null && clipData.ItemCount > 0)
{
for (int i = 0; i < clipData.ItemCount; i++)
{
var item = clipData.GetItemAt(i);
var result = GetFileFromUri(item.Uri);
if (result != null)
{
fileResults.Add(result);
}
}
}
else
{
Uri fileUri = data.Data;
var result = GetFileFromUri(fileUri);
if (result != null)
{
fileResults.Add(result);
}
}
}
_completionSource?.TrySetResult(fileResults);
}
var mainActivity = Platform.CurrentActivity as MainActivity;
mainActivity.ActivityResult -= OnActivityResult;
}
Read the file from the storage and return the MediaFile
The code for reading a file from the storage in Android is a bit needlessly complex. The information is like a database (MediaStore) that you can query and we need to follow a certain procedure in order to get the file from the Uri, like this:
1) Get the ContentResolver
from the current activity
2) Make a string projection with the columns (in this case we need the Id
and Data
columns from the MediaStore)
3) Call the Query method from the ContentResolver and pass the Uri from the file to get a Cursor
4) If the Cursor has information, then query the Id column and get its string data (which contains the actual path of the file)
5) If the Cursor doesn't have the information, then we need to try and get the file id from the DocumentsContract and make a new projection with this id
6) Once we have the Id we need to get a new Cursor using the projection and InternalContentUri
or the ExternalContentUri
if the former fails
7) Finally, query the data column get and its string Data which should contain the actual file path for you to use
private MediaFile GetFileFromLegacyUri(Uri uri)
{
MediaFile result = null;
ICursor imageCursor = null;
try
{
var contentResolver = Platform.CurrentActivity.ContentResolver;
const string idColumn = MediaStore.Images.ImageColumns.Id;
const string dataColumn = MediaStore.Images.ImageColumns.Data;
var internalContentUri = MediaStore.Images.Media.InternalContentUri;
var externalContentUri = MediaStore.Images.Media.ExternalContentUri;
result = new MediaFile();
var projection = new string[] {dataColumn};
imageCursor = contentResolver.Query(uri, null, null, null, null,null);
if (imageCursor != null)
{
imageCursor.MoveToFirst();
int dataIndex = imageCursor.GetColumnIndex(dataColumn);
if (dataIndex != -1)
{
var mime = contentResolver.GetType(uri);
var idIndex = imageCursor.GetColumnIndexOrThrow(idColumn);
var path = imageCursor.GetString(idIndex);
result.MimeType = mime;
result.PathUri = new System.Uri(path);
result.FileStream = System.IO.File.OpenRead(path);
}
else
{
var documentId = DocumentsContract.GetDocumentId(uri);
var pictureId = documentId.Contains(":") ? documentId.Split(":")[1] : documentId;
var whereSelection = idColumn + "=?";
imageCursor = contentResolver.Query(internalContentUri, projection, whereSelection,
new string[] {pictureId}, null,null);
if (imageCursor.Count == 0)
{
imageCursor = contentResolver.Query(externalContentUri, projection, whereSelection,
new string[] {pictureId}, null, null);
}
var columnData = imageCursor.GetColumnIndexOrThrow(dataColumn);
imageCursor.MoveToFirst();
var path = imageCursor.GetString(columnData);
result.FileStream = System.IO.File.OpenRead(path);
}
}
return result;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
finally
{
if (imageCursor != null)
{
imageCursor.Close();
imageCursor.Dispose();
}
}
}
Loading the images from the storage on Android 10 (API Level 29) and up
When I was trying to load the images from the storage on my emulator I noticed the code in the previous section doesn't work and and every file I tried to load was causing an exception citing missing permissions even though the Storage permission was approved.
Most code you can find on the Internet for loading files from the storage is identical to that code above and after a long search I found that from Android 10 and on there is a new policy called Scoped storage and this causes an exception saying you're not authorized to read the file. The good thing is that there is a simplified way to get a file from the storage once you have the Uri.
For this we are going to use the OpenInputStream method from the ContentResolver
.
private MediaFile GetScopedFileFromUri(Uri uri)
{
MediaFile result = null;
try
{
var contentResolver = Platform.CurrentActivity.ContentResolver;
var mime = contentResolver.GetType(uri);
result = new MediaFile();
result.MimeType = mime;
result.FileStream = contentResolver.OpenInputStream(uri);
return result;
}
catch (Exception e)
{
Console.WriteLine(e);
return result;
}
}
Having this in mind we need to add code to get the file when the user has Android 10+ and use our legacy code when not.
private MediaFile GetFileFromUri(Uri uri)
{
if (Android.OS.Build.VERSION.SdkInt >= BuildVersionCodes.P)
{
return GetScopedFileFromUri(uri);
}
else
{
return GetFileFromLegacyUri(uri);
}
}
Opting-out of ScopedStorage
On Android 10 (API Level 29 or in Android 11 with API Level 29 as target) you have the option to set a flag in the AndroidManifest.xml
to opt-out of ScopedStorage and use the legacy method for reading files from the storage, but this flag will be ignored when you target API Level 30 and above.
Although it didn't work when I tested it on the emulator, some people have said that it does work on Android 10.
<manifest ... >
<!-- This attribute is "false" by default on apps targeting
Android 10 or higher. -->
<application android:requestLegacyExternalStorage="true" ... >
...
</application>
</manifest>
Conclusions
It's been a while since I last published here so I wanted to share my findings in this aspect when I got that unexpected error. The code is a bit verbose due to the nature of Android but once you understand the process it's ezpz.
I hope this can be useful to you and stay tuned for the next post, where I'll be showing you have to make an outlined material entry in Xamarin.Forms.
References
Select Multiple Images and Videos in Xamarin Forms by XamBoy
https://www.xamboy.com/2019/03/12/select-multiple-images-and-videos-in-xamarin-forms/
Select multiple images from the gallery in Xamarin Forms by Daniel Kondrashevich
https://medium.com/swlh/select-multiple-images-from-the-gallery-in-xamarin-forms-df2e037be572
Xamarin.Essentials Media Picker
https://docs.microsoft.com/en-us/xamarin/essentials/media-picker?tabs=android
Storage updated in Android 11
https://developer.android.com/about/versions/11/privacy/storage
Opting out of scoped storage
https://developer.android.com/training/data-storage/use-cases#opt-out-scoped-storage
Android docs for opening media files
https://developer.android.com/training/data-storage/shared/media#open-file
Top comments (0)