浏览代码

Add android implementation

Max Katz 3 年之前
父节点
当前提交
eb403ea15e

+ 0 - 3
src/Android/Avalonia.Android/AndroidPlatform.cs

@@ -1,10 +1,8 @@
 using System;
 using Avalonia.Controls;
-using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Android;
 using Avalonia.Android.Platform;
 using Avalonia.Android.Platform.Input;
-using Avalonia.Controls.Platform;
 using Avalonia.Input;
 using Avalonia.Input.Platform;
 using Avalonia.OpenGL.Egl;
@@ -55,7 +53,6 @@ namespace Avalonia.Android
                 .Bind<IKeyboardDevice>().ToSingleton<AndroidKeyboardDevice>()
                 .Bind<IPlatformSettings>().ToConstant(Instance)
                 .Bind<IPlatformThreadingInterface>().ToConstant(new AndroidThreadingInterface())
-                .Bind<ISystemDialogImpl>().ToTransient<SystemDialogImpl>()
                 .Bind<IPlatformIconLoader>().ToSingleton<PlatformIconLoaderStub>()
                 .Bind<IRenderTimer>().ToConstant(new ChoreographerTimer())
                 .Bind<IRenderLoop>().ToConstant(new RenderLoop())

+ 27 - 5
src/Android/Avalonia.Android/AvaloniaActivity.cs

@@ -4,10 +4,14 @@ using Android.Content.Res;
 using AndroidX.Lifecycle;
 using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Controls;
+using Android.Runtime;
+using Android.App;
+using Android.Content;
+using System;
 
 namespace Avalonia.Android
 {
-    public abstract class AvaloniaActivity<TApp> : AppCompatActivity where TApp : Application, new()
+    public abstract class AvaloniaActivity : AppCompatActivity
     {
         internal class SingleViewLifetime : ISingleViewApplicationLifetime
         {
@@ -20,16 +24,15 @@ namespace Avalonia.Android
             }
         }
 
+        internal Action<int, Result, Intent> ActivityResult;
         internal AvaloniaView View;
         internal AvaloniaViewModel _viewModel;
 
-        protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder.UseAndroid();
+        protected abstract AppBuilder CreateAppBuilder();
 
         protected override void OnCreate(Bundle savedInstanceState)
         {
-            var builder = AppBuilder.Configure<TApp>();
-            
-            CustomizeAppBuilder(builder);
+            var builder = CreateAppBuilder();
 
 
             var lifetime = new SingleViewLifetime();
@@ -79,5 +82,24 @@ namespace Avalonia.Android
 
             base.OnDestroy();
         }
+
+        protected override void OnActivityResult(int requestCode, [GeneratedEnum] Result resultCode, Intent data)
+        {
+            base.OnActivityResult(requestCode, resultCode, data);
+
+            ActivityResult?.Invoke(requestCode, resultCode, data);
+        }
+    }
+
+    public abstract class AvaloniaActivity<TApp> : AvaloniaActivity where TApp : Application, new()
+    {
+        protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder.UseAndroid();
+
+        protected override AppBuilder CreateAppBuilder()
+        {
+            var builder = AppBuilder.Configure<TApp>();
+
+            return CustomizeAppBuilder(builder);
+        }
     }
 }

+ 7 - 1
src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs

@@ -7,6 +7,7 @@ using Android.Views.InputMethods;
 using Avalonia.Android.OpenGL;
 using Avalonia.Android.Platform.Specific;
 using Avalonia.Android.Platform.Specific.Helpers;
+using Avalonia.Android.Platform.Storage;
 using Avalonia.Controls;
 using Avalonia.Controls.Platform;
 using Avalonia.Controls.Platform.Surfaces;
@@ -16,11 +17,13 @@ using Avalonia.Input.TextInput;
 using Avalonia.OpenGL.Egl;
 using Avalonia.OpenGL.Surfaces;
 using Avalonia.Platform;
+using Avalonia.Platform.Storage;
 using Avalonia.Rendering;
 
 namespace Avalonia.Android.Platform.SkiaPlatform
 {
-    class TopLevelImpl : IAndroidView, ITopLevelImpl, EglGlPlatformSurfaceBase.IEglWindowGlPlatformSurfaceInfo, ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost
+    class TopLevelImpl : IAndroidView, ITopLevelImpl, EglGlPlatformSurfaceBase.IEglWindowGlPlatformSurfaceInfo,
+        ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost, ITopLevelImplWithStorageProvider
     {
         private readonly IGlPlatformSurface _gl;
         private readonly IFramebufferPlatformSurface _framebuffer;
@@ -46,6 +49,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
                 _view.Resources.DisplayMetrics.HeightPixels).ToSize(RenderScaling);
 
             NativeControlHost = new AndroidNativeControlHostImpl(avaloniaView);
+            StorageProvider = new AndroidStorageProvider((AvaloniaActivity)avaloniaView.Context);
         }
 
         public virtual Point GetAvaloniaPointFromEvent(MotionEvent e, int pointerIndex) =>
@@ -225,6 +229,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform
         public ITextInputMethodImpl TextInputMethod => _textInputMethod;
 
         public INativeControlHostImpl NativeControlHost { get; }
+        
+        public IStorageProvider StorageProvider { get; }
 
         public void SetTransparencyLevelHint(WindowTransparencyLevel transparencyLevel)
         {

+ 244 - 0
src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs

@@ -0,0 +1,244 @@
+#nullable enable
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Android.Content;
+using Android.Provider;
+using Avalonia.Logging;
+using Avalonia.Platform.Storage;
+using Java.Lang;
+using AndroidUri = Android.Net.Uri;
+using Exception = System.Exception;
+using JavaFile = Java.IO.File;
+
+namespace Avalonia.Android.Platform.Storage;
+
+internal abstract class AndroidStorageItem : IStorageBookmarkItem
+{
+    private Context? _context;
+
+    protected AndroidStorageItem(Context context, AndroidUri uri)
+    {
+        _context = context;
+        Uri = uri;
+    }
+
+    internal AndroidUri Uri { get; }
+
+    protected Context Context => _context ?? throw new ObjectDisposedException(nameof(AndroidStorageItem));
+
+    public string Name => GetColumnValue(Context, Uri, MediaStore.IMediaColumns.DisplayName)
+                          ?? Uri.PathSegments?.LastOrDefault() ?? string.Empty;
+
+    public bool CanBookmark => true;
+
+    public Task<string?> SaveBookmark()
+    {
+        Context.ContentResolver?.TakePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
+        return Task.FromResult(Uri.ToString());
+    }
+
+    public Task ReleaseBookmark()
+    {
+        Context.ContentResolver?.ReleasePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
+        return Task.CompletedTask;
+    }
+
+    public bool TryGetUri([NotNullWhen(true)] out Uri? uri)
+    {
+        uri = new Uri(Uri.ToString()!);
+        return true;
+    }
+
+    public abstract Task<StorageItemProperties> GetBasicPropertiesAsync();
+
+    protected string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null)
+    {
+        try
+        {
+            var projection = new[] { column };
+            using var cursor = context.ContentResolver!.Query(contentUri, projection, selection, selectionArgs, null);
+            if (cursor?.MoveToFirst() == true)
+            {
+                var columnIndex = cursor.GetColumnIndex(column);
+                if (columnIndex != -1)
+                    return cursor.GetString(columnIndex);
+            }
+        }
+        catch (Exception ex)
+        {
+            Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "File metadata reader failed: '{Exception}'", ex);
+        }
+
+        return null;
+    }
+
+    public Task<IStorageFolder?> GetParentAsync()
+    {
+        using var javaFile = new JavaFile(Uri.Path!);
+
+        // Java file represents files AND directories. Don't be confused.
+        if (javaFile.ParentFile is {} parentFile
+            && AndroidUri.FromFile(parentFile) is {} androidUri)
+        {
+            return Task.FromResult<IStorageFolder?>(new AndroidStorageFolder(Context, androidUri));
+        }
+
+        return Task.FromResult<IStorageFolder?>(null);
+    }
+
+    public void Dispose()
+    {
+        _context = null;
+    }
+}
+
+internal sealed class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
+{
+    public AndroidStorageFolder(Context context, AndroidUri uri) : base(context, uri)
+    {
+    }
+
+    public override Task<StorageItemProperties> GetBasicPropertiesAsync()
+    {
+        return Task.FromResult(new StorageItemProperties());
+    }
+}
+
+internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkFile
+{
+    public AndroidStorageFile(Context context, AndroidUri uri) : base(context, uri)
+    {
+    }
+
+    public bool CanOpenRead => true;
+
+    public bool CanOpenWrite => true;
+
+    public Task<Stream> OpenRead() => Task.FromResult(OpenContentStream(Context, Uri, false)
+        ?? throw new InvalidOperationException("Failed to open content stream"));
+
+    public Task<Stream> OpenWrite() => Task.FromResult(OpenContentStream(Context, Uri, true)
+        ?? throw new InvalidOperationException("Failed to open content stream"));
+
+    private Stream? OpenContentStream(Context context, AndroidUri uri, bool isOutput)
+    {
+        var isVirtual = IsVirtualFile(context, uri);
+        if (isVirtual)
+        {
+            Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Content URI was virtual: '{Uri}'", uri);
+            return GetVirtualFileStream(context, uri, isOutput);
+        }
+
+        return isOutput
+            ? context.ContentResolver?.OpenOutputStream(uri)
+            : context.ContentResolver?.OpenInputStream(uri);
+    }
+
+    private bool IsVirtualFile(Context context, AndroidUri uri)
+    {
+        if (!DocumentsContract.IsDocumentUri(context, uri))
+            return false;
+
+        var value = GetColumnValue(context, 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;
+    }
+
+    private Stream? GetVirtualFileStream(Context context, AndroidUri uri, bool isOutput)
+    {
+        var mimeTypes = context.ContentResolver?.GetStreamTypes(uri, FilePickerFileTypes.All.MimeTypes![0]);
+        if (mimeTypes?.Length >= 1)
+        {
+            var mimeType = mimeTypes[0];
+            var asset = context.ContentResolver!
+                .OpenTypedAssetFileDescriptor(uri, mimeType, null);
+
+            var stream = isOutput
+                ? asset?.CreateOutputStream()
+                : asset?.CreateInputStream();
+
+            return stream;
+        }
+
+        return null;
+    }
+
+    public override Task<StorageItemProperties> GetBasicPropertiesAsync()
+    {
+        ulong? size = null;
+        DateTimeOffset? itemDate = null;
+        DateTimeOffset? dateModified = null;
+
+        try
+        {
+            var projection = new[]
+            {
+                MediaStore.IMediaColumns.Size, MediaStore.IMediaColumns.DateAdded,
+                MediaStore.IMediaColumns.DateModified
+            };
+            using var cursor = Context.ContentResolver!.Query(Uri, projection, null, null, null);
+
+            if (cursor?.MoveToFirst() == true)
+            {
+                try
+                {
+                    var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.Size);
+                    if (columnIndex != -1)
+                    {
+                        size = (ulong)cursor.GetLong(columnIndex);
+                    }
+                }
+                catch (Exception ex)
+                {
+                    Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
+                        .Log(this, "File Size metadata reader failed: '{Exception}'", ex);
+                }
+
+                try
+                {
+                    var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.DateAdded);
+                    if (columnIndex != -1)
+                    {
+                        var longValue = cursor.GetLong(columnIndex);
+                        itemDate = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null;
+                    }
+                }
+                catch (Exception ex)
+                {
+                    Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
+                        .Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex);
+                }
+
+                try
+                {
+                    var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.DateModified);
+                    if (columnIndex != -1)
+                    {
+                        var longValue = cursor.GetLong(columnIndex);
+                        dateModified = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null;
+                    }
+                }
+                catch (Exception ex)
+                {
+                    Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
+                        .Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex);
+                }
+            }
+        }
+        catch (UnsupportedOperationException)
+        {
+            // It's not possible to get parameters of some files/folders.
+        }
+
+        return Task.FromResult(new StorageItemProperties(size, itemDate, dateModified));
+    }
+}

+ 177 - 0
src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs

@@ -0,0 +1,177 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Android.App;
+using Android.Content;
+using Android.Provider;
+using Avalonia.Platform.Storage;
+using AndroidUri = Android.Net.Uri;
+
+namespace Avalonia.Android.Platform.Storage;
+
+internal class AndroidStorageProvider : IStorageProvider
+{
+    private readonly AvaloniaActivity _activity;
+    private int _lastRequestCode = 20000;
+
+    public AndroidStorageProvider(AvaloniaActivity activity)
+    {
+        _activity = activity;
+    }
+
+    public bool CanOpen => OperatingSystem.IsAndroidVersionAtLeast(19);
+
+    public bool CanSave => OperatingSystem.IsAndroidVersionAtLeast(19);
+
+    public bool CanPickFolder => OperatingSystem.IsAndroidVersionAtLeast(21);
+
+    public Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark)
+    {
+        var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark));
+        return Task.FromResult<IStorageBookmarkFolder?>(new AndroidStorageFolder(_activity, uri));
+    }
+
+    public Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark)
+    {
+        var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark));
+        return Task.FromResult<IStorageBookmarkFile?>(new AndroidStorageFile(_activity, uri));
+    }
+
+    public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
+    {
+        var mimeTypes = options.FileTypeFilter?.Where(t => t != FilePickerFileTypes.All)
+            .SelectMany(f => f.MimeTypes ?? Array.Empty<string>()).Distinct().ToArray() ?? Array.Empty<string>();
+
+        var intent = new Intent(Intent.ActionOpenDocument)
+            .AddCategory(Intent.CategoryOpenable)
+            .PutExtra(Intent.ExtraAllowMultiple, options.AllowMultiple)
+            .SetType(FilePickerFileTypes.All.MimeTypes![0]);
+        if (mimeTypes.Length > 0)
+        {
+            intent = intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes);
+        }
+
+        if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri)
+        {
+            intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri);
+        }
+
+        var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Select file");
+
+        var uris = await StartActivity(pickerIntent, false);
+        return uris.Select(u => new AndroidStorageFile(_activity, u)).ToArray();
+    }
+
+    public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
+    {
+        var mimeTypes = options.FileTypeChoices?.Where(t => t != FilePickerFileTypes.All)
+            .SelectMany(f => f.MimeTypes ?? Array.Empty<string>()).Distinct().ToArray() ?? Array.Empty<string>();
+
+        var intent = new Intent(Intent.ActionCreateDocument)
+            .AddCategory(Intent.CategoryOpenable)
+            .SetType(FilePickerFileTypes.All.MimeTypes![0]);
+        if (mimeTypes.Length > 0)
+        {
+            intent = intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes);
+        }
+
+        if (options.SuggestedFileName is { } fileName)
+        {
+            if (options.DefaultExtension is { } ext)
+            {
+                fileName += ext.StartsWith('.') ? ext : "." + ext;
+            }
+            intent = intent.PutExtra(Intent.ExtraTitle, fileName);
+        }
+
+        if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri)
+        {
+            intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri);
+        }
+
+        var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Save file");
+
+        var uris = await StartActivity(pickerIntent, true);
+        return uris.Select(u => new AndroidStorageFile(_activity, u)).FirstOrDefault();
+    }
+
+    public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
+    {
+        var intent = new Intent(Intent.ActionOpenDocumentTree)
+            .PutExtra(Intent.ExtraAllowMultiple, options.AllowMultiple);
+        if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri)
+        {
+            intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri);
+        }
+
+        var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Select folder");
+
+        var uris = await StartActivity(pickerIntent, false);
+        return uris.Select(u => new AndroidStorageFolder(_activity, u)).ToArray();
+    }
+
+    private async Task<List<AndroidUri>> StartActivity(Intent? pickerIntent, bool singleResult)
+    {
+        var resultList = new List<AndroidUri>(1);
+        var tcs = new TaskCompletionSource<Intent?>();
+        var currentRequestCode = _lastRequestCode++;
+
+        _activity.ActivityResult += OnActivityResult;
+        _activity.StartActivityForResult(pickerIntent, currentRequestCode);
+
+        var result = await tcs.Task;
+
+        if (result != null)
+        {
+            // ClipData first to avoid issue with multiple files selection.
+            if (!singleResult && result.ClipData is { } clipData)
+            {
+                for (var i = 0; i < clipData.ItemCount; i++)
+                {
+                    var uri = clipData.GetItemAt(i)?.Uri;
+                    if (uri != null)
+                    {
+                        resultList.Add(uri);
+                    }
+                }
+            }
+            else if (result.Data is { } uri)
+            {
+                resultList.Add(uri);
+            }
+        }
+
+        if (result?.HasExtra("error") == true)
+        {
+            throw new Exception(result.GetStringExtra("error"));
+        }
+
+        return resultList;
+
+        void OnActivityResult(int requestCode, Result resultCode, Intent data)
+        {
+            if (currentRequestCode != requestCode)
+            {
+                return;
+            }
+
+            _activity.ActivityResult -= OnActivityResult;
+
+            _ = tcs.TrySetResult(resultCode == Result.Ok ? data : null);
+        }
+    }
+
+    private static AndroidUri? TryGetInitialUri(IStorageFolder? folder)
+    {
+        if (OperatingSystem.IsAndroidVersionAtLeast(26)
+            && (folder as AndroidStorageItem)?.Uri is { } uri)
+        {
+            return uri;
+        }
+
+        return null;
+    }
+}

+ 0 - 20
src/Android/Avalonia.Android/SystemDialogImpl.cs

@@ -1,20 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using Avalonia.Controls;
-using Avalonia.Controls.Platform;
-
-namespace Avalonia.Android
-{
-    internal class SystemDialogImpl : ISystemDialogImpl
-    {
-        public Task<string[]> ShowFileDialogAsync(FileDialog dialog, Window parent)
-        {
-            throw new NotImplementedException();
-        }
-
-        public Task<string> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent)
-        {
-            throw new NotImplementedException();
-        }
-    }
-}