Rework the "picker" results to correctly manage files (#1555)

* Rework the "picker" results to correctly manage lifetime of files and URIs
* Extract magic strings into constants
   - Mime types
   - Extensions
   - Changed property type of ShareMultipleFilesRequest.Files to be a List<T> for consistency
This commit is contained in:
Matthew Leibowitz 2020-12-02 15:58:07 +02:00 коммит произвёл GitHub
Родитель ba911949b6
Коммит add928f57b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
20 изменённых файлов: 579 добавлений и 250 удалений

Просмотреть файл

@ -13,26 +13,35 @@
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<queries>
<!-- Email -->
<intent>
<action android:name="android.intent.action.SENDTO" />
<data android:scheme="mailto" />
</intent>
<!-- Browser -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
<!-- Browser -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<!-- Sms -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="smsto" />
</intent>
<!-- PhoneDialer -->
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
<!-- MediaPicker -->
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
</queries>
<uses-feature android:name="android.hardware.location" android:required="false" />
<uses-feature android:name="android.hardware.location.gps" android:required="false" />

Просмотреть файл

@ -1,4 +1,5 @@
using System.IO;
using System.Collections.Generic;
using System.IO;
using System.Windows.Input;
using Samples.Helpers;
using Xamarin.Essentials;
@ -157,7 +158,7 @@ namespace Samples.ViewModel
await Share.RequestAsync(new ShareMultipleFilesRequest
{
Title = ShareFilesTitle,
Files = new ShareFile[] { new ShareFile(file1), new ShareFile(file2) },
Files = new List<ShareFile> { new ShareFile(file1), new ShareFile(file2) },
PresentationSourceBounds = GetRectangle(element)
});
}

Просмотреть файл

@ -17,5 +17,26 @@ namespace Tests
{
await Assert.ThrowsAsync<NotImplementedInReferenceAssemblyException>(() => FileSystem.OpenAppPackageFileAsync("filename.txt"));
}
[Theory]
[InlineData(null, "")]
[InlineData("", "")]
[InlineData(".", ".")]
[InlineData(".txt", ".txt")]
[InlineData("*.txt", ".txt")]
[InlineData("*.*", ".*")]
[InlineData("txt", ".txt")]
[InlineData("test.txt", ".test.txt")]
[InlineData("test.", ".test.")]
[InlineData("....txt", ".txt")]
[InlineData("******txt", ".txt")]
[InlineData("******.txt", ".txt")]
[InlineData("******.......txt", ".txt")]
public void Extensions_Clean_Correctly_Cleans_Extensions(string input, string output)
{
var cleaned = FileSystem.Extensions.Clean(input);
Assert.Equal(output, cleaned);
}
}
}

Просмотреть файл

@ -45,7 +45,7 @@ namespace Xamarin.Essentials
if (action == Intent.ActionSendto)
intent.SetData(Uri.Parse("mailto:"));
else
intent.SetType("message/rfc822");
intent.SetType(FileSystem.MimeTypes.EmailMessage);
if (!string.IsNullOrEmpty(message?.Body))
{

Просмотреть файл

@ -2,11 +2,8 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.Provider;
namespace Xamarin.Essentials
{
@ -22,7 +19,7 @@ namespace Xamarin.Essentials
var action = Intent.ActionOpenDocument;
var intent = new Intent(action);
intent.SetType("*/*");
intent.SetType(FileSystem.MimeTypes.All);
intent.PutExtra(Intent.ExtraAllowMultiple, allowMultiple);
var allowedTypes = options?.FileTypes?.Value?.ToArray();
@ -33,23 +30,30 @@ namespace Xamarin.Essentials
try
{
var result = await IntermediateActivity.StartAsync(pickerIntent, Platform.requestCodeFilePicker);
var resultList = new List<FileResult>();
var clipData = new List<global::Android.Net.Uri>();
if (result.ClipData == null)
void OnResult(Intent intent)
{
clipData.Add(result.Data);
}
else
{
for (var i = 0; i < result.ClipData.ItemCount; i++)
clipData.Add(result.ClipData.GetItemAt(i).Uri);
// The uri returned is only temporary and only lives as long as the Activity that requested it,
// so this means that it will always be cleaned up by the time we need it because we are using
// an intermediate activity.
if (intent.ClipData == null)
{
var path = FileSystem.EnsurePhysicalPath(intent.Data);
resultList.Add(new FileResult(path));
}
else
{
for (var i = 0; i < intent.ClipData.ItemCount; i++)
{
var uri = intent.ClipData.GetItemAt(i).Uri;
var path = FileSystem.EnsurePhysicalPath(uri);
resultList.Add(new FileResult(path));
}
}
}
foreach (var contentUri in clipData)
resultList.Add(new FileResult(contentUri));
await IntermediateActivity.StartAsync(pickerIntent, Platform.requestCodeFilePicker, onResult: OnResult);
return resultList;
}
@ -65,31 +69,31 @@ namespace Xamarin.Essentials
static FilePickerFileType PlatformImageFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.Android, new[] { "image/png", "image/jpeg" } }
{ DevicePlatform.Android, new[] { FileSystem.MimeTypes.ImagePng, FileSystem.MimeTypes.ImageJpg } }
});
static FilePickerFileType PlatformPngFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.Android, new[] { "image/png" } }
{ DevicePlatform.Android, new[] { FileSystem.MimeTypes.ImagePng } }
});
static FilePickerFileType PlatformJpegFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.Android, new[] { "image/jpeg" } }
{ DevicePlatform.Android, new[] { FileSystem.MimeTypes.ImageJpg } }
});
static FilePickerFileType PlatformVideoFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.Android, new[] { "video/*" } }
{ DevicePlatform.Android, new[] { FileSystem.MimeTypes.VideoAll } }
});
static FilePickerFileType PlatformPdfFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.Android, new[] { "application/pdf" } }
{ DevicePlatform.Android, new[] { FileSystem.MimeTypes.Pdf } }
});
}
}

Просмотреть файл

@ -34,7 +34,6 @@ namespace Xamarin.Essentials
{
try
{
// there was a cancellation
tcs.TrySetResult(GetFileResults(urls));
}
catch (Exception ex)
@ -72,13 +71,10 @@ namespace Xamarin.Essentials
return tcs.Task;
}
static IEnumerable<FileResult> GetFileResults(NSUrl[] urls)
{
if (urls?.Length > 0)
return urls.Select(url => new UIDocumentFileResult(url));
else
return Enumerable.Empty<FileResult>();
}
static IEnumerable<FileResult> GetFileResults(NSUrl[] urls) =>
urls?.Length > 0
? urls.Select(url => new UIDocumentFileResult(url))
: Enumerable.Empty<FileResult>();
class PickerDelegate : UIDocumentPickerDelegate
{

Просмотреть файл

@ -24,7 +24,7 @@ namespace Xamarin.Essentials
appControl.LaunchMode = AppControlLaunchMode.Single;
var fileType = options?.FileTypes?.Value?.FirstOrDefault();
appControl.Mime = fileType ?? "*/*";
appControl.Mime = fileType ?? FileSystem.MimeTypes.All;
var fileResults = new List<FileResult>();
@ -51,31 +51,31 @@ namespace Xamarin.Essentials
static FilePickerFileType PlatformImageFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.Tizen, new[] { "image/*" } },
{ DevicePlatform.Tizen, new[] { FileSystem.MimeTypes.ImageAll } },
});
static FilePickerFileType PlatformPngFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.Tizen, new[] { "image/png" } }
{ DevicePlatform.Tizen, new[] { FileSystem.MimeTypes.ImagePng } }
});
static FilePickerFileType PlatformJpegFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.Tizen, new[] { "image/jpeg" } }
{ DevicePlatform.Tizen, new[] { FileSystem.MimeTypes.ImageJpg } }
});
static FilePickerFileType PlatformVideoFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.Tizen, new[] { "video/*" } }
{ DevicePlatform.Tizen, new[] { FileSystem.MimeTypes.VideoAll } }
});
static FilePickerFileType PlatformPdfFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.Tizen, new[] { "application/pdf" } }
{ DevicePlatform.Tizen, new[] { FileSystem.MimeTypes.Pdf } }
});
}
}

Просмотреть файл

@ -49,9 +49,10 @@ namespace Xamarin.Essentials
{
foreach (var type in options.FileTypes.Value)
{
if (type.StartsWith(".") || type.StartsWith("*."))
var ext = FileSystem.Extensions.Clean(type);
if (!string.IsNullOrWhiteSpace(ext))
{
picker.FileTypeFilter.Add(type.TrimStart('*'));
picker.FileTypeFilter.Add(ext);
hasAtLeastOneType = true;
}
}
@ -67,31 +68,31 @@ namespace Xamarin.Essentials
static FilePickerFileType PlatformImageFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.UWP, new[] { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp" } }
{ DevicePlatform.UWP, FileSystem.Extensions.AllImage }
});
static FilePickerFileType PlatformPngFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.UWP, new[] { "*.png" } }
{ DevicePlatform.UWP, new[] { FileSystem.Extensions.Png } }
});
static FilePickerFileType PlatformJpegFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.UWP, new[] { "*.jpg", "*.jpeg" } }
{ DevicePlatform.UWP, FileSystem.Extensions.AllJpeg }
});
static FilePickerFileType PlatformVideoFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.UWP, new[] { "*.mp4", "*.mov", "*.avi", "*.wmv", "*.m4v", "*.mpg", "*.mpeg", "*.mp2", "*.mkv", "*.flv", "*.gifv", "*.qt" } }
{ DevicePlatform.UWP, FileSystem.Extensions.AllVideo }
});
static FilePickerFileType PlatformPdfFileType() =>
new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.UWP, new[] { "*.pdf" } }
{ DevicePlatform.UWP, new[] { FileSystem.Extensions.Pdf } }
});
}
}

Просмотреть файл

@ -1,15 +1,36 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using Android.App;
using Android.Provider;
using Android.Webkit;
using AndroidUri = Android.Net.Uri;
namespace Xamarin.Essentials
{
public partial class FileSystem
{
internal const string EssentialsFolderHash = "2203693cc04e0be7f4f024d5f9499e13";
const string storageTypePrimary = "primary";
const string storageTypeRaw = "raw";
const string storageTypeImage = "image";
const string storageTypeVideo = "video";
const string storageTypeAudio = "audio";
static readonly string[] contentUriPrefixes =
{
"content://downloads/public_downloads",
"content://downloads/my_downloads",
"content://downloads/all_downloads",
};
internal const string UriSchemeFile = "file";
internal const string UriSchemeContent = "content";
internal const string UriAuthorityExternalStorage = "com.android.externalstorage.documents";
internal const string UriAuthorityDownloads = "com.android.providers.downloads.documents";
internal const string UriAuthorityMedia = "com.android.providers.media.documents";
static string PlatformCacheDirectory
=> Platform.AppContext.CacheDir.AbsolutePath;
@ -31,67 +52,292 @@ namespace Xamarin.Essentials
throw new FileNotFoundException(ex.Message, filename, ex);
}
}
}
public partial class FileBase
{
internal FileBase(Java.IO.File file)
: this(file?.Path)
internal static Java.IO.File GetEssentialsTemporaryFile(Java.IO.File root, string fileName)
{
// create the directory for all Essentials files
var rootDir = new Java.IO.File(root, EssentialsFolderHash);
rootDir.Mkdirs();
rootDir.DeleteOnExit();
// create a unique directory just in case there are multiple file with the same name
var tmpDir = new Java.IO.File(rootDir, Guid.NewGuid().ToString("N"));
tmpDir.Mkdirs();
tmpDir.DeleteOnExit();
// create the new temporary file
var tmpFile = new Java.IO.File(tmpDir, fileName);
tmpFile.DeleteOnExit();
return tmpFile;
}
internal FileBase(global::Android.Net.Uri contentUri)
: this(GetFullPath(contentUri))
{
this.contentUri = contentUri;
FileName = GetFileName(contentUri);
}
readonly global::Android.Net.Uri contentUri;
internal static string PlatformGetContentType(string extension) =>
MimeTypeMap.Singleton.GetMimeTypeFromExtension(extension.TrimStart('.'));
static string GetFullPath(global::Android.Net.Uri contentUri)
internal static string EnsurePhysicalPath(AndroidUri uri)
{
// if this is a file, use that
if (contentUri.Scheme == "file")
return contentUri.Path;
if (uri.Scheme.Equals(UriSchemeFile, StringComparison.OrdinalIgnoreCase))
return uri.Path;
// ask the content provider for the data column, which may contain the actual file path
// try resolve using the content provider
var absolute = ResolvePhysicalPath(uri);
if (!string.IsNullOrWhiteSpace(absolute) && Path.IsPathRooted(absolute))
return absolute;
// fall back to just copying it
absolute = CacheContentFile(uri);
if (!string.IsNullOrWhiteSpace(absolute) && Path.IsPathRooted(absolute))
return absolute;
throw new FileNotFoundException($"Unable to resolve absolute path or retrieve contents of URI '{uri}'.");
}
static string ResolvePhysicalPath(AndroidUri uri)
{
if (Platform.HasApiLevelKitKat && DocumentsContract.IsDocumentUri(Platform.AppContext, uri))
{
var resolved = ResolveDocumentPath(uri);
if (File.Exists(resolved))
return resolved;
}
if (uri.Scheme.Equals(UriSchemeContent, StringComparison.OrdinalIgnoreCase))
{
var resolved = ResolveContentPath(uri);
if (File.Exists(resolved))
return resolved;
}
else if (uri.Scheme.Equals(UriSchemeFile, StringComparison.OrdinalIgnoreCase))
{
var resolved = uri.Path;
if (File.Exists(resolved))
return resolved;
}
return null;
}
static string ResolveDocumentPath(AndroidUri uri)
{
Debug.WriteLine($"Trying to resolve document URI: '{uri}'");
var docId = DocumentsContract.GetDocumentId(uri);
var docIdParts = docId?.Split(':');
if (docIdParts == null || docIdParts.Length == 0)
return null;
if (uri.Authority.Equals(UriAuthorityExternalStorage, StringComparison.OrdinalIgnoreCase))
{
Debug.WriteLine($"Resolving external storage URI: '{uri}'");
if (docIdParts.Length == 2)
{
var storageType = docIdParts[0];
var uriPath = docIdParts[1];
// This is the internal "external" memory, NOT the SD Card
if (storageType.Equals(storageTypePrimary, StringComparison.OrdinalIgnoreCase))
{
#pragma warning disable CS0618 // Type or member is obsolete
var path = QueryContentResolverColumn(contentUri, MediaStore.Files.FileColumns.Data);
var root = global::Android.OS.Environment.ExternalStorageDirectory.Path;
#pragma warning restore CS0618 // Type or member is obsolete
return Path.Combine(root, uriPath);
}
// TODO: support other types, such as actual SD Cards
}
}
else if (uri.Authority.Equals(UriAuthorityDownloads, StringComparison.OrdinalIgnoreCase))
{
Debug.WriteLine($"Resolving downloads URI: '{uri}'");
// NOTE: This only really applies to older Android vesions since the privacy changes
if (docIdParts.Length == 2)
{
var storageType = docIdParts[0];
var uriPath = docIdParts[1];
if (storageType.Equals(storageTypeRaw, StringComparison.OrdinalIgnoreCase))
return uriPath;
}
// ID could be "###" or "msf:###"
var fileId = docIdParts.Length == 2
? docIdParts[1]
: docIdParts[0];
foreach (var prefix in contentUriPrefixes)
{
var uriString = prefix + "/" + fileId;
var contentUri = AndroidUri.Parse(uriString);
if (GetDataFilePath(contentUri) is string filePath)
return filePath;
}
}
else if (uri.Authority.Equals(UriAuthorityMedia, StringComparison.OrdinalIgnoreCase))
{
Debug.WriteLine($"Resolving media URI: '{uri}'");
if (docIdParts.Length == 2)
{
var storageType = docIdParts[0];
var uriPath = docIdParts[1];
AndroidUri contentUri = null;
if (storageType.Equals(storageTypeImage, StringComparison.OrdinalIgnoreCase))
contentUri = MediaStore.Images.Media.ExternalContentUri;
else if (storageType.Equals(storageTypeVideo, StringComparison.OrdinalIgnoreCase))
contentUri = MediaStore.Video.Media.ExternalContentUri;
else if (storageType.Equals(storageTypeAudio, StringComparison.OrdinalIgnoreCase))
contentUri = MediaStore.Audio.Media.ExternalContentUri;
if (contentUri != null && GetDataFilePath(contentUri, $"{MediaStore.MediaColumns.Id}=?", new[] { uriPath }) is string filePath)
return filePath;
}
}
Debug.WriteLine($"Unable to resolve document URI: '{uri}'");
return null;
}
static string ResolveContentPath(AndroidUri uri)
{
Debug.WriteLine($"Trying to resolve content URI: '{uri}'");
if (GetDataFilePath(uri) is string filePath)
return filePath;
// TODO: support some additional things, like Google Photos if that is possible
Debug.WriteLine($"Unable to resolve content URI: '{uri}'");
return null;
}
static string CacheContentFile(AndroidUri uri)
{
if (!uri.Scheme.Equals(UriSchemeContent, StringComparison.OrdinalIgnoreCase))
return null;
Debug.WriteLine($"Copying content URI to local cache: '{uri}'");
// open the source stream
using var srcStream = OpenContentStream(uri, out var extension);
if (srcStream == null)
return null;
// resolve or generate a valid destination path
var filename = GetColumnValue(uri, MediaStore.Files.FileColumns.DisplayName) ?? Guid.NewGuid().ToString("N");
if (!Path.HasExtension(filename) && !string.IsNullOrEmpty(extension))
filename = Path.ChangeExtension(filename, extension);
// create a temporary file
var tmpFile = GetEssentialsTemporaryFile(Platform.AppContext.CacheDir, filename);
// copy to the destination
using var dstStream = File.Create(tmpFile.CanonicalPath);
srcStream.CopyTo(dstStream);
return tmpFile.CanonicalPath;
}
static Stream OpenContentStream(AndroidUri uri, out string extension)
{
var isVirtual = IsVirtualFile(uri);
if (isVirtual)
{
Debug.WriteLine($"Content URI was virtual: '{uri}'");
return GetVirtualFileStream(uri, out extension);
}
extension = GetFileExtension(uri);
return Platform.ContentResolver.OpenInputStream(uri);
}
static bool IsVirtualFile(AndroidUri uri)
{
if (!DocumentsContract.IsDocumentUri(Platform.AppContext, uri))
return false;
var value = GetColumnValue(uri, DocumentsContract.Document.ColumnFlags);
if (!string.IsNullOrEmpty(value) && int.TryParse(value, out var flagsInt))
{
var flags = (DocumentContractFlags)flagsInt;
return flags.HasFlag(DocumentContractFlags.VirtualDocument);
}
return false;
}
static Stream GetVirtualFileStream(AndroidUri uri, out string extension)
{
var mimeTypes = Platform.ContentResolver.GetStreamTypes(uri, FileSystem.MimeTypes.All);
if (mimeTypes?.Length >= 1)
{
var mimeType = mimeTypes[0];
var stream = Platform.ContentResolver
.OpenTypedAssetFileDescriptor(uri, mimeType, null)
.CreateInputStream();
extension = MimeTypeMap.Singleton.GetExtensionFromMimeType(mimeType);
return stream;
}
extension = null;
return null;
}
static string GetColumnValue(AndroidUri contentUri, string column, string selection = null, string[] selectionArgs = null)
{
try
{
var value = QueryContentResolverColumn(contentUri, column, selection, selectionArgs);
if (!string.IsNullOrEmpty(value))
return value;
}
catch
{
// Ignore all exceptions and use null for the error indicator
}
return null;
}
static string GetDataFilePath(AndroidUri contentUri, string selection = null, string[] selectionArgs = null)
{
#pragma warning disable CS0618 // Type or member is obsolete
const string column = MediaStore.Files.FileColumns.Data;
#pragma warning restore CS0618 // Type or member is obsolete
// ask the content provider for the data column, which may contain the actual file path
var path = GetColumnValue(contentUri, column, selection, selectionArgs);
if (!string.IsNullOrEmpty(path) && Path.IsPathRooted(path))
return path;
// fallback: use content URI
return contentUri.ToString();
return null;
}
static string GetFileName(global::Android.Net.Uri contentUri)
static string GetFileExtension(AndroidUri uri)
{
// resolve file name by querying content provider for display name
var filename = QueryContentResolverColumn(contentUri, MediaStore.MediaColumns.DisplayName);
var mimeType = Platform.ContentResolver.GetType(uri);
if (string.IsNullOrWhiteSpace(filename))
{
filename = Path.GetFileName(WebUtility.UrlDecode(contentUri.ToString()));
}
if (!Path.HasExtension(filename))
filename = filename.TrimEnd('.') + '.' + GetFileExtensionFromUri(contentUri);
return filename;
return mimeType != null
? MimeTypeMap.Singleton.GetExtensionFromMimeType(mimeType)
: null;
}
static string QueryContentResolverColumn(global::Android.Net.Uri contentUri, string columnName)
static string QueryContentResolverColumn(AndroidUri contentUri, string columnName, string selection = null, string[] selectionArgs = null)
{
string text = null;
var projection = new[] { columnName };
using var cursor = Application.Context.ContentResolver.Query(contentUri, projection, null, null, null);
using var cursor = Platform.ContentResolver.Query(contentUri, projection, selection, selectionArgs, null);
if (cursor?.MoveToFirst() == true)
{
var columnIndex = cursor.GetColumnIndex(columnName);
@ -101,35 +347,26 @@ namespace Xamarin.Essentials
return text;
}
}
static string GetFileExtensionFromUri(global::Android.Net.Uri uri)
public partial class FileBase
{
internal FileBase(Java.IO.File file)
: this(file?.Path)
{
var mimeType = Application.Context.ContentResolver.GetType(uri);
return mimeType != null ? global::Android.Webkit.MimeTypeMap.Singleton.GetExtensionFromMimeType(mimeType) : string.Empty;
}
internal static string PlatformGetContentType(string extension) =>
MimeTypeMap.Singleton.GetMimeTypeFromExtension(extension.TrimStart('.'));
internal void PlatformInit(FileBase file)
{
}
internal virtual Task<Stream> PlatformOpenReadAsync()
{
if (contentUri?.Scheme == "content")
{
var content = Application.Context.ContentResolver.OpenInputStream(contentUri);
return Task.FromResult(content);
}
var stream = File.OpenRead(FullPath);
return Task.FromResult<Stream>(stream);
}
}
public partial class FileResult
{
internal FileResult(global::Android.Net.Uri contentUri)
: base(contentUri)
{
}
}
}

Просмотреть файл

@ -45,7 +45,7 @@ namespace Xamarin.Essentials
{
uiImage = image;
FullPath = Guid.NewGuid().ToString() + ".png";
FullPath = Guid.NewGuid().ToString() + FileSystem.Extensions.Png;
FileName = FullPath;
}

Просмотреть файл

@ -14,11 +14,77 @@ namespace Xamarin.Essentials
public static Task<Stream> OpenAppPackageFileAsync(string filename)
=> PlatformOpenAppPackageFileAsync(filename);
internal static class MimeTypes
{
internal const string All = "*/*";
internal const string ImageAll = "image/*";
internal const string ImagePng = "image/png";
internal const string ImageJpg = "image/jpeg";
internal const string VideoAll = "video/*";
internal const string EmailMessage = "message/rfc822";
internal const string Pdf = "application/pdf";
internal const string TextPlain = "text/plain";
internal const string OctetStream = "application/octet-stream";
}
internal static class Extensions
{
internal const string Png = ".png";
internal const string Jpg = ".jpg";
internal const string Jpeg = ".jpeg";
internal const string Gif = ".gif";
internal const string Bmp = ".bmp";
internal const string Avi = ".avi";
internal const string Flv = ".flv";
internal const string Gifv = ".gifv";
internal const string Mp4 = ".mp4";
internal const string M4v = ".m4v";
internal const string Mpg = ".mpg";
internal const string Mpeg = ".mpeg";
internal const string Mp2 = ".mp2";
internal const string Mkv = ".mkv";
internal const string Mov = ".mov";
internal const string Qt = ".qt";
internal const string Wmv = ".wmv";
internal const string Pdf = ".pdf";
internal static string[] AllImage =>
new[] { Png, Jpg, Jpeg, Gif, Bmp };
internal static string[] AllJpeg =>
new[] { Jpg, Jpeg };
internal static string[] AllVideo =>
new[] { Mp4, Mov, Avi, Wmv, M4v, Mpg, Mpeg, Mp2, Mkv, Flv, Gifv, Qt };
internal static string Clean(string extension, bool trimLeadingPeriod = false)
{
if (string.IsNullOrWhiteSpace(extension))
return string.Empty;
extension = extension.TrimStart('*');
extension = extension.TrimStart('.');
if (!trimLeadingPeriod)
extension = "." + extension;
return extension;
}
}
}
public abstract partial class FileBase
{
internal const string DefaultContentType = "application/octet-stream";
internal const string DefaultContentType = FileSystem.MimeTypes.OctetStream;
string contentType;
@ -76,7 +142,8 @@ namespace Xamarin.Essentials
if (!string.IsNullOrWhiteSpace(content))
return content;
}
return "application/octet-stream";
return DefaultContentType;
}
string fileName;

Просмотреть файл

@ -45,7 +45,7 @@ namespace Xamarin.Essentials
var appControl = new AppControl
{
Operation = AppControlOperations.View,
Mime = "*/*",
Mime = FileSystem.MimeTypes.All,
Uri = "file://" + request.File.FullPath,
};

Просмотреть файл

@ -1,13 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.Provider;
using AndroidUri = Android.Net.Uri;
namespace Xamarin.Essentials
{
@ -24,20 +20,30 @@ namespace Xamarin.Essentials
static async Task<FileResult> PlatformPickAsync(MediaPickerOptions options, bool photo)
{
// we only need the permission when accessing the file, but it's more natural
// We only need the permission when accessing the file, but it's more natural
// to ask the user first, then show the picker.
await Permissions.RequestAsync<Permissions.StorageRead>();
var intent = new Intent(Intent.ActionGetContent);
intent.SetType(photo ? "image/*" : "video/*");
intent.SetType(photo ? FileSystem.MimeTypes.ImageAll : FileSystem.MimeTypes.VideoAll);
var pickerIntent = Intent.CreateChooser(intent, options?.Title);
try
{
var result = await IntermediateActivity.StartAsync(pickerIntent, Platform.requestCodeMediaPicker);
string path = null;
void OnResult(Intent intent)
{
// The uri returned is only temporary and only lives as long as the Activity that requested it,
// so this means that it will always be cleaned up by the time we need it because we are using
// an intermediate activity.
return new FileResult(result.Data);
path = FileSystem.EnsurePhysicalPath(intent.Data);
}
await IntermediateActivity.StartAsync(pickerIntent, Platform.requestCodeMediaPicker, onResult: OnResult);
return new FileResult(path);
}
catch (OperationCanceledException)
{
@ -57,32 +63,47 @@ namespace Xamarin.Essentials
await Permissions.EnsureGrantedAsync<Permissions.StorageWrite>();
var capturePhotoIntent = new Intent(photo ? MediaStore.ActionImageCapture : MediaStore.ActionVideoCapture);
if (capturePhotoIntent.ResolveActivity(Platform.AppContext.PackageManager) != null)
if (!Platform.IsIntentSupported(capturePhotoIntent))
throw new FeatureNotSupportedException($"Either there was no camera on the device or '{capturePhotoIntent.Action}' was not added to the <queries> element in the app's manifest file. See more: https://developer.android.com/about/versions/11/privacy/package-visibility");
capturePhotoIntent.AddFlags(ActivityFlags.GrantReadUriPermission);
capturePhotoIntent.AddFlags(ActivityFlags.GrantWriteUriPermission);
try
{
try
var activity = Platform.GetCurrentActivity(true);
// Create the temporary file
var ext = photo
? FileSystem.Extensions.Jpg
: FileSystem.Extensions.Mp4;
var fileName = Guid.NewGuid().ToString("N") + ext;
var tmpFile = FileSystem.GetEssentialsTemporaryFile(Platform.AppContext.CacheDir, fileName);
// Set up the content:// uri
AndroidUri outputUri = null;
void OnCreate(Intent intent)
{
var activity = Platform.GetCurrentActivity(true);
// Android requires that using a file provider to get a content:// uri for a file to be called
// from within the context of the actual activity which may share that uri with another intent
// it launches.
var storageDir = Platform.AppContext.ExternalCacheDir;
var tmpFile = Java.IO.File.CreateTempFile(Guid.NewGuid().ToString(), photo ? ".jpg" : ".mp4", storageDir);
tmpFile.DeleteOnExit();
outputUri ??= FileProvider.GetUriForFile(tmpFile);
capturePhotoIntent.AddFlags(ActivityFlags.GrantReadUriPermission);
capturePhotoIntent.AddFlags(ActivityFlags.GrantWriteUriPermission);
var result = await IntermediateActivity.StartAsync(capturePhotoIntent, Platform.requestCodeMediaCapture, tmpFile);
var outputUri = result.GetParcelableExtra(IntermediateActivity.OutputUriExtra) as global::Android.Net.Uri;
return new FileResult(outputUri);
}
catch (OperationCanceledException)
{
return null;
intent.PutExtra(MediaStore.ExtraOutput, outputUri);
}
// Start the capture process
await IntermediateActivity.StartAsync(capturePhotoIntent, Platform.requestCodeMediaCapture, OnCreate);
// Return the file that we just captured
return new FileResult(tmpFile.AbsolutePath);
}
catch (OperationCanceledException)
{
return null;
}
return null;
}
}
}

Просмотреть файл

@ -16,9 +16,8 @@ namespace Xamarin.Essentials
{
get
{
var packageManager = Platform.AppContext.PackageManager;
var dialIntent = ResolveDialIntent(intentCheck);
return dialIntent.ResolveActivity(packageManager) != null;
return Platform.IsIntentSupported(dialIntent);
}
}

Просмотреть файл

@ -1,6 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Android.App;
@ -12,7 +11,6 @@ using Android.Locations;
using Android.Net;
using Android.Net.Wifi;
using Android.OS;
using Android.Provider;
using Android.Views;
using AndroidIntent = Android.Content.Intent;
using AndroidUri = Android.Net.Uri;
@ -130,12 +128,11 @@ namespace Xamarin.Essentials
return false;
}
internal static bool IsIntentSupported(AndroidIntent intent)
{
var manager = AppContext.PackageManager;
var activities = manager.QueryIntentActivities(intent, PackageInfoFlags.MatchDefaultOnly);
return activities.Any();
}
internal static bool IsIntentSupported(AndroidIntent intent) =>
intent.ResolveActivity(AppContext.PackageManager) != null;
internal static bool IsIntentSupported(AndroidIntent intent, string expectedPackageName) =>
intent.ResolveActivity(AppContext.PackageManager) is ComponentName c && c.PackageName == expectedPackageName;
internal static AndroidUri GetShareableFileUri(FileBase file)
{
@ -147,28 +144,11 @@ namespace Xamarin.Essentials
}
else
{
var rootDir = FileProvider.GetTemporaryDirectory();
var root = FileProvider.GetTemporaryRootDirectory();
// create a unique directory just in case there are multiple file with the same name
var tmpDir = new Java.IO.File(rootDir, Guid.NewGuid().ToString("N"));
tmpDir.Mkdirs();
tmpDir.DeleteOnExit();
var tmpFile = FileSystem.GetEssentialsTemporaryFile(root, file.FileName);
// create the new temprary file
var tmpFile = new Java.IO.File(tmpDir, file.FileName);
tmpFile.DeleteOnExit();
var fileUri = AndroidUri.Parse(file.FullPath);
if (fileUri.Scheme == "content")
{
using var stream = Application.Context.ContentResolver.OpenInputStream(fileUri);
using var destStream = System.IO.File.Create(tmpFile.CanonicalPath);
stream.CopyTo(destStream);
}
else
{
System.IO.File.Copy(file.FullPath, tmpFile.CanonicalPath);
}
System.IO.File.Copy(file.FullPath, tmpFile.CanonicalPath);
sharedFile = tmpFile;
}
@ -187,47 +167,15 @@ namespace Xamarin.Essentials
return AndroidUri.FromFile(sharedFile);
}
internal static bool HasApiLevelN =>
#if __ANDROID_24__
HasApiLevel(BuildVersionCodes.N);
#else
false;
#endif
internal static bool HasApiLevelKitKat => HasApiLevel(BuildVersionCodes.Kitkat);
internal static bool HasApiLevelNMr1 =>
#if __ANDROID_25__
HasApiLevel(BuildVersionCodes.NMr1);
#else
false;
#endif
internal static bool HasApiLevelN => HasApiLevel(24);
internal static bool HasApiLevelO =>
#if __ANDROID_26__
HasApiLevel(BuildVersionCodes.O);
#else
false;
#endif
internal static bool HasApiLevelNMr1 => HasApiLevel(25);
internal static bool HasApiLevelOMr1 =>
#if __ANDROID_27__
HasApiLevel(BuildVersionCodes.OMr1);
#else
false;
#endif
internal static bool HasApiLevelO => HasApiLevel(26);
internal static bool HasApiLevelP =>
#if __ANDROID_28__
HasApiLevel(BuildVersionCodes.P);
#else
false;
#endif
internal static bool HasApiLevelQ =>
#if __ANDROID_29__
HasApiLevel(BuildVersionCodes.Q);
#else
false;
#endif
internal static bool HasApiLevelQ => HasApiLevel(29);
static int? sdkInt;
@ -237,6 +185,9 @@ namespace Xamarin.Essentials
internal static bool HasApiLevel(BuildVersionCodes versionCode) =>
SdkInt >= (int)versionCode;
internal static bool HasApiLevel(int apiLevel) =>
SdkInt >= apiLevel;
internal static CameraManager CameraManager =>
AppContext.GetSystemService(Context.CameraService) as CameraManager;
@ -387,19 +338,14 @@ namespace Xamarin.Essentials
const string actualIntentExtra = "actual_intent";
const string guidExtra = "guid";
const string requestCodeExtra = "request_code";
const string outputExtra = "output";
internal const string OutputUriExtra = "output_uri";
static readonly ConcurrentDictionary<string, TaskCompletionSource<Intent>> pendingTasks =
new ConcurrentDictionary<string, TaskCompletionSource<Intent>>();
static readonly ConcurrentDictionary<string, IntermediateTask> pendingTasks =
new ConcurrentDictionary<string, IntermediateTask>();
bool launched;
Intent actualIntent;
string guid;
int requestCode;
string output;
global::Android.Net.Uri outputUri;
protected override void OnCreate(Bundle savedInstanceState)
{
@ -412,14 +358,10 @@ namespace Xamarin.Essentials
actualIntent = extras.GetParcelable(actualIntentExtra) as Intent;
guid = extras.GetString(guidExtra);
requestCode = extras.GetInt(requestCodeExtra, -1);
output = extras.GetString(outputExtra, null);
if (!string.IsNullOrEmpty(output))
if (GetIntermediateTask(guid) is IntermediateTask task)
{
var javaFile = new Java.IO.File(output);
var providerAuthority = FileProvider.Authority;
outputUri = FileProvider.GetUriForFile(Platform.AppContext, providerAuthority, javaFile);
actualIntent.PutExtra(MediaStore.ExtraOutput, outputUri);
task.OnCreate?.Invoke(actualIntent);
}
// if this is the first time, lauch the real activity
@ -436,7 +378,6 @@ namespace Xamarin.Essentials
outState.PutParcelable(actualIntentExtra, actualIntent);
outState.PutString(guidExtra, guid);
outState.PutInt(requestCodeExtra, requestCode);
outState.PutString(outputExtra, output);
base.OnSaveInstanceState(outState);
}
@ -446,21 +387,26 @@ namespace Xamarin.Essentials
base.OnActivityResult(requestCode, resultCode, data);
// we have a valid GUID, so handle the task
if (!string.IsNullOrEmpty(guid) && pendingTasks.TryRemove(guid, out var tcs) && tcs != null)
if (GetIntermediateTask(guid, true) is IntermediateTask task)
{
if (resultCode == Result.Canceled)
{
tcs.TrySetCanceled();
task.TaskCompletionSource.TrySetCanceled();
}
else
{
if (outputUri != null)
try
{
data ??= new AndroidIntent();
data.PutExtra(OutputUriExtra, outputUri);
}
tcs.TrySetResult(data);
task.OnResult?.Invoke(data);
task.TaskCompletionSource.TrySetResult(data);
}
catch (Exception ex)
{
task.TaskCompletionSource.TrySetException(ex);
}
}
}
@ -468,30 +414,60 @@ namespace Xamarin.Essentials
Finish();
}
public static Task<Intent> StartAsync(Intent intent, int requestCode, Java.IO.File extraOutput = null)
public static Task<Intent> StartAsync(Intent intent, int requestCode, Action<Intent> onCreate = null, Action<Intent> onResult = null)
{
// make sure we have the activity
var activity = Platform.GetCurrentActivity(true);
var tcs = new TaskCompletionSource<Intent>();
// create a new task
var guid = Guid.NewGuid().ToString();
pendingTasks[guid] = tcs;
var data = new IntermediateTask(onCreate, onResult);
pendingTasks[data.Id] = data;
// create the intermediate intent, and add the real intent to it
var intermediateIntent = new Intent(activity, typeof(IntermediateActivity));
intermediateIntent.PutExtra(actualIntentExtra, intent);
intermediateIntent.PutExtra(guidExtra, guid);
intermediateIntent.PutExtra(guidExtra, data.Id);
intermediateIntent.PutExtra(requestCodeExtra, requestCode);
if (extraOutput != null)
intermediateIntent.PutExtra(outputExtra, extraOutput.AbsolutePath);
// start the intermediate activity
activity.StartActivityForResult(intermediateIntent, requestCode);
return tcs.Task;
return data.TaskCompletionSource.Task;
}
static IntermediateTask GetIntermediateTask(string guid, bool remove = false)
{
if (string.IsNullOrEmpty(guid))
return null;
if (remove)
{
pendingTasks.TryRemove(guid, out var removedTask);
return removedTask;
}
pendingTasks.TryGetValue(guid, out var task);
return task;
}
class IntermediateTask
{
public IntermediateTask(Action<Intent> onCreate, Action<AndroidIntent> onResult)
{
Id = Guid.NewGuid().ToString();
TaskCompletionSource = new TaskCompletionSource<Intent>();
OnCreate = onCreate;
OnResult = onResult;
}
public string Id { get; }
public TaskCompletionSource<Intent> TaskCompletionSource { get; }
public Action<Intent> OnCreate { get; }
public Action<AndroidIntent> OnResult { get; }
}
}
}

Просмотреть файл

@ -23,7 +23,7 @@ namespace Xamarin.Essentials
}
var intent = new Intent(Intent.ActionSend);
intent.SetType("text/plain");
intent.SetType(FileSystem.MimeTypes.TextPlain);
intent.PutExtra(Intent.ExtraText, string.Join(System.Environment.NewLine, items));
if (!string.IsNullOrWhiteSpace(request.Subject))
@ -46,7 +46,10 @@ namespace Xamarin.Essentials
foreach (var file in request.Files)
contentUris.Add(Platform.GetShareableFileUri(file));
intent.SetType(request.Files.Count() > 1 ? "*/*" : request.Files.FirstOrDefault().ContentType);
var type = request.Files.Count > 1
? FileSystem.MimeTypes.All
: request.Files.FirstOrDefault().ContentType;
intent.SetType(type);
intent.SetFlags(ActivityFlags.GrantReadUriPermission);
intent.PutParcelableArrayListExtra(Intent.ExtraStream, contentUris);

Просмотреть файл

@ -116,7 +116,8 @@ namespace Xamarin.Essentials
{
}
public ShareMultipleFilesRequest(IEnumerable<ShareFile> files) => Files = files;
public ShareMultipleFilesRequest(IEnumerable<ShareFile> files) =>
Files = files.ToList();
public ShareMultipleFilesRequest(IEnumerable<FileBase> files)
: this(ConvertList(files))
@ -131,7 +132,7 @@ namespace Xamarin.Essentials
{
}
public IEnumerable<ShareFile> Files { get; set; }
public List<ShareFile> Files { get; set; }
public static explicit operator ShareMultipleFilesRequest(ShareFileRequest request)
{

Просмотреть файл

@ -44,7 +44,7 @@ namespace Xamarin.Essentials
if (!string.IsNullOrWhiteSpace(packageName))
{
intent = new Intent(Intent.ActionSend);
intent.SetType("text/plain");
intent.SetType(FileSystem.MimeTypes.TextPlain);
intent.PutExtra(Intent.ExtraText, body);
intent.SetPackage(packageName);

Просмотреть файл

@ -4,6 +4,7 @@ using Android.App;
using Android.Content;
using Android.OS;
using AndroidEnvironment = Android.OS.Environment;
using AndroidUri = Android.Net.Uri;
#if __ANDROID_29__
using ContentFileProvider = AndroidX.Core.Content.FileProvider;
#else
@ -30,15 +31,6 @@ namespace Xamarin.Essentials
internal static string Authority => Platform.AppContext.PackageName + ".fileProvider";
internal static Java.IO.File GetTemporaryDirectory()
{
var root = GetTemporaryRootDirectory();
var dir = new Java.IO.File(root, "2203693cc04e0be7f4f024d5f9499e13");
dir.Mkdirs();
dir.DeleteOnExit();
return dir;
}
internal static Java.IO.File GetTemporaryRootDirectory()
{
// If we specifically want the internal storage, no extra checks are needed, we have permission
@ -124,6 +116,9 @@ namespace Xamarin.Essentials
return false;
}
internal static AndroidUri GetUriForFile(Java.IO.File file) =>
FileProvider.GetUriForFile(Platform.AppContext, Authority, file);
}
public enum FileProviderLocation

Просмотреть файл

@ -59,9 +59,7 @@ namespace Xamarin.Essentials
intent.SetData(global::Android.Net.Uri.Parse(callbackUrl.OriginalString));
// Try to find the activity for the callback intent
var c = intent.ResolveActivity(Platform.AppContext.PackageManager);
if (c == null || c.PackageName != packageName)
if (!Platform.IsIntentSupported(intent, packageName))
throw new InvalidOperationException($"You must subclass the `{nameof(WebAuthenticatorCallbackActivity)}` and create an IntentFilter for it which matches your `{nameof(callbackUrl)}`.");
// Cancel any previous task that's still pending