diff --git a/.gitignore b/.gitignore index 20de9957c..43c3fe5c9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ ApiKeys.Private.cs SecureFolderFS.WinUI/settings/ SecureFolderFS.AvaloniaUI/settings/ +pdfjs53/ # User-specific files *.rsuser diff --git a/.gitmodules b/.gitmodules index 1f2e435bb..44f18cf87 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/Tmds.Fuse"] path = lib/Tmds.Fuse url = https://github.com/securefolderfs-community/Tmds.Fuse +[submodule "src/Platforms/SecureFolderFS.Dashboard"] + path = src/Platforms/SecureFolderFS.Dashboard + url = git@github.com:securefolderfs-community/SecureFolderFS.Dashboard.git diff --git a/SecureFolderFS.sln b/SecureFolderFS.sln index 588353e7a..740359e3b 100644 --- a/SecureFolderFS.sln +++ b/SecureFolderFS.sln @@ -64,6 +64,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{724C2A9B EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.Tests", "tests\SecureFolderFS.Tests\SecureFolderFS.Tests.csproj", "{67ED86B1-D287-4F36-A8BE-189F68502B4C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.Dashboard", "src\Platforms\SecureFolderFS.Dashboard\SecureFolderFS.Dashboard.csproj", "{9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -396,6 +398,22 @@ Global {67ED86B1-D287-4F36-A8BE-189F68502B4C}.Release|x64.Build.0 = Release|Any CPU {67ED86B1-D287-4F36-A8BE-189F68502B4C}.Release|x86.ActiveCfg = Release|Any CPU {67ED86B1-D287-4F36-A8BE-189F68502B4C}.Release|x86.Build.0 = Release|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Debug|arm64.ActiveCfg = Debug|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Debug|arm64.Build.0 = Debug|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Debug|x64.ActiveCfg = Debug|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Debug|x64.Build.0 = Debug|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Debug|x86.ActiveCfg = Debug|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Debug|x86.Build.0 = Debug|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Release|Any CPU.Build.0 = Release|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Release|arm64.ActiveCfg = Release|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Release|arm64.Build.0 = Release|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Release|x64.ActiveCfg = Release|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Release|x64.Build.0 = Release|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Release|x86.ActiveCfg = Release|Any CPU + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -425,6 +443,7 @@ Global {2EAA63F7-399A-4079-9F70-E9D318812ADC} = {66BC1E2B-D99A-49E2-8B8F-EF7851493CB0} {F6C5CDF6-D86A-4C78-8649-F5AF983DA3A8} = {F2ACE2B7-1599-4769-8FF4-41FA03B25D26} {67ED86B1-D287-4F36-A8BE-189F68502B4C} = {724C2A9B-20B9-4FBA-B0F7-F2BFDB10B52C} + {9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0} = {66BC1E2B-D99A-49E2-8B8F-EF7851493CB0} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A1906FD8-BB54-4688-BC0F-9ED7532D2CB0} diff --git a/global.json b/global.json index f42869719..4d9e028b1 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "msbuild-sdks": { - "Uno.Sdk": "5.4.10" + "Uno.Sdk": "6.0.96" } -} +} \ No newline at end of file diff --git a/lib/Tmds.Fuse b/lib/Tmds.Fuse index 73663e78b..e4bf64788 160000 --- a/lib/Tmds.Fuse +++ b/lib/Tmds.Fuse @@ -1 +1 @@ -Subproject commit 73663e78ba98e20850ecad10489028f2afedd60f +Subproject commit e4bf6478812ba61773f0a7e6f42710a2b4a70bdd diff --git a/lib/nwebdav b/lib/nwebdav index 17fcebe4d..733265e9b 160000 --- a/lib/nwebdav +++ b/lib/nwebdav @@ -1 +1 @@ -Subproject commit 17fcebe4d06338f94122400c2c9cfdbb5ea8c77e +Subproject commit 733265e9b66044efb7b76b9d7b07aca437632614 diff --git a/src/Platforms/Directory.Packages.props b/src/Platforms/Directory.Packages.props index 6cbaa6ad1..06bdb9946 100644 --- a/src/Platforms/Directory.Packages.props +++ b/src/Platforms/Directory.Packages.props @@ -2,31 +2,29 @@ - - - + + + - + - - - - - - - - - + + + + + + + - - - - + + + + + - - + @@ -35,6 +33,9 @@ - + + + + - + \ No newline at end of file diff --git a/src/Platforms/SecureFolderFS.Dashboard b/src/Platforms/SecureFolderFS.Dashboard new file mode 160000 index 000000000..6b605e61d --- /dev/null +++ b/src/Platforms/SecureFolderFS.Dashboard @@ -0,0 +1 @@ +Subproject commit 6b605e61dcdf739c78a660738740087d0cb521ce diff --git a/src/Platforms/SecureFolderFS.Maui/App.xaml b/src/Platforms/SecureFolderFS.Maui/App.xaml index 11e594fae..cd4219349 100644 --- a/src/Platforms/SecureFolderFS.Maui/App.xaml +++ b/src/Platforms/SecureFolderFS.Maui/App.xaml @@ -1,17 +1,14 @@  + xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"> - + + - - - diff --git a/src/Platforms/SecureFolderFS.Maui/App.xaml.cs b/src/Platforms/SecureFolderFS.Maui/App.xaml.cs index 4fa5aaaa5..2aadcab1a 100644 --- a/src/Platforms/SecureFolderFS.Maui/App.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/App.xaml.cs @@ -1,4 +1,7 @@ -using CommunityToolkit.Mvvm.DependencyInjection; +using APES.UI.XF; +using SecureFolderFS.Maui.Extensions.Mappers; +using SecureFolderFS.Maui.Helpers; +using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared; using SecureFolderFS.UI.Helpers; @@ -6,8 +9,8 @@ namespace SecureFolderFS.Maui { public partial class App : Application { - public static App Instance => (App)Application.Current!; - + public static App Instance => (App)Current!; + public IServiceProvider? ServiceProvider { get; private set; } public BaseLifecycleHelper ApplicationLifecycle { get; } = @@ -18,7 +21,7 @@ public partial class App : Application #else null; #endif - + public event EventHandler? AppResumed; public event EventHandler? AppPutToForeground; @@ -26,6 +29,16 @@ public App() { InitializeComponent(); +#if ANDROID + // Load Android-specific resource dictionaries + Resources.MergedDictionaries.Add(new Platforms.Android.Templates.AndroidDataTemplates()); +#endif + + // Configure mappers + CustomMappers.AddEntryMappers(); + CustomMappers.AddLabelMappers(); + CustomMappers.AddPickerMappers(); + // Configure exception handlers AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; @@ -33,8 +46,8 @@ public App() protected override Window CreateWindow(IActivationState? activationState) { - APES.UI.XF.ContextMenuContainer.Init(); - + ContextMenuContainer.Init(); + var appShell = Task.Run(GetAppShellAsync).ConfigureAwait(false).GetAwaiter().GetResult(); return new Window(appShell); } @@ -50,9 +63,16 @@ private async Task GetAppShellAsync() // Register IoC DI.Default.SetServiceProvider(ServiceProvider); + // Initialize Telemetry + var telemetryService = DI.Service(); + await telemetryService.EnableTelemetryAsync(); + // Create and initialize AppShell var appShell = new AppShell(); - await appShell.MainViewModel.InitAsync(); + await appShell.MainViewModel.InitAsync().ConfigureAwait(false); + + // Initialize ThemeHelper + await MauiThemeHelper.Instance.InitAsync().ConfigureAwait(false); return appShell; } diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/CorrectedPanGestureRecognizer.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/CorrectedPanGestureRecognizer.cs new file mode 100644 index 000000000..f69e9efc6 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/CorrectedPanGestureRecognizer.cs @@ -0,0 +1,49 @@ +// Some parts of the following code were used from https://github.com/dotnet/maui/issues/20772#issuecomment-2030914069 + +using Microsoft.Maui.Platform; + +namespace SecureFolderFS.Maui.AppModels +{ + internal sealed class CorrectedPanGestureRecognizer : PanGestureRecognizer, IPanGestureController + { +#if ANDROID + // Index 0 is X, index 1 is Y + private readonly int[] startingLocation = new int[2]; + private readonly int[] currentLocation = new int[2]; +#endif + public new event EventHandler? PanUpdated; + + void IPanGestureController.SendPan(Element sender, double totalX, double totalY, int gestureId) + { +#if ANDROID + ArgumentNullException.ThrowIfNull(sender.Handler.MauiContext?.Context); + Android.Views.View view = sender.ToPlatform(sender.Handler.MauiContext); + view.GetLocationOnScreen(currentLocation); + totalX += sender.Handler.MauiContext.Context.FromPixels(currentLocation[0] - startingLocation[0]); + totalY += sender.Handler.MauiContext.Context.FromPixels(currentLocation[1] - startingLocation[1]); + +#endif + PanUpdated?.Invoke(sender, new PanUpdatedEventArgs(GestureStatus.Running, gestureId, totalX, totalY)); + } + + void IPanGestureController.SendPanCanceled(Element sender, int gestureId) + { + PanUpdated?.Invoke(sender, new PanUpdatedEventArgs(GestureStatus.Canceled, gestureId)); + } + + void IPanGestureController.SendPanCompleted(Element sender, int gestureId) + { + PanUpdated?.Invoke(sender, new PanUpdatedEventArgs(GestureStatus.Completed, gestureId)); + } + + void IPanGestureController.SendPanStarted(Element sender, int gestureId) + { +#if ANDROID + ArgumentNullException.ThrowIfNull(sender.Handler.MauiContext); + Android.Views.View view = sender.ToPlatform(sender.Handler.MauiContext); + view.GetLocationOnScreen(startingLocation); +#endif + PanUpdated?.Invoke(sender, new PanUpdatedEventArgs(GestureStatus.Started, gestureId)); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs index d80b3989a..bced85220 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs @@ -1,25 +1,38 @@ +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; using IImage = SecureFolderFS.Shared.ComponentModel.IImage; namespace SecureFolderFS.Maui.AppModels { /// - internal sealed class ImageStream : IImage + internal sealed class ImageStream : IImageStream { - private readonly Stream _stream; - + public Stream Stream { get; } + public StreamImageSource Source { get; } public ImageStream(Stream stream) { - _stream = stream; + Stream = stream; Source = new(); - Source.Stream = (cancellationToken) => Task.FromResult(stream); + Source.Stream = (ct) => Task.FromResult(stream); + } + + /// + public async Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default) + { + var savedPosition = Stream.Position; + await Stream.CopyToAsync(destination, cancellationToken); + Stream.Position = savedPosition; } /// public void Dispose() { - _stream.Dispose(); + if (Stream is OnDemandDisposableStream onDemandDisposableStream) + onDemandDisposableStream.ForceClose(); + else + Stream.Dispose(); } } } diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/PdfStreamServer.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/PdfStreamServer.cs new file mode 100644 index 000000000..ec1c10253 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/PdfStreamServer.cs @@ -0,0 +1,186 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using SecureFolderFS.Shared.ComponentModel; + +#if ANDROID +using Android.Content.Res; +using SecureFolderFS.Shared.Helpers; +#endif + +namespace SecureFolderFS.Maui.AppModels +{ + internal sealed class PdfStreamServer : IAsyncInitialize, IDisposable + { + private readonly HttpListener _httpListener; + private readonly Stream _fileStream; + private readonly string _mimeType; + private readonly int _port; + private bool _disposed; + + public string BaseAddress => $"http://localhost:{_port}"; + + public PdfStreamServer(Stream fileStream, string mimeType) + { + if (!fileStream.CanSeek) + throw new ArgumentException("Stream must be seekable.", nameof(fileStream)); + + _fileStream = fileStream; + _mimeType = mimeType; + + // Automatically find a free port + _port = GetAvailablePort(); + + // Initialize the HttpListener with the free port + _httpListener = new HttpListener(); + _httpListener.Prefixes.Add($"http://localhost:{_port}/"); + } + + /// + public Task InitAsync(CancellationToken cancellationToken = default) + { + _httpListener.Start(); + _ = BeginListeningAsync(); + + return Task.CompletedTask; + + async Task BeginListeningAsync() + { + try + { + while (!_disposed && _httpListener.IsListening && await _httpListener.GetContextAsync() is var context) + { + var response = context.Response; + var absolutePath = context.Request.Url?.AbsolutePath ?? string.Empty; + + try + { + if (absolutePath == "/app_file") + { + response.ContentType = _mimeType; + response.Headers["Accept-Ranges"] = "bytes"; + response.ContentLength64 = _fileStream.Length; + + await _fileStream.CopyToAsync(response.OutputStream, cancellationToken); + if (_fileStream.CanSeek) + _fileStream.Position = 0L; + + response.StatusCode = (int)HttpStatusCode.OK; + response.StatusDescription = "OK"; + } + else if (absolutePath.StartsWith("/pdfjs53")) + { +#if ANDROID + var relativePath = absolutePath.TrimStart('/'); + var contentType = FileTypeHelper.GetMimeType(relativePath); + response.ContentType = contentType; + response.Headers["Accept-Ranges"] = "bytes"; + + await using var assetStream = Android.App.Application.Context.Assets?.Open(relativePath, Access.Random); + if (assetStream is null) + { + response.StatusCode = (int)HttpStatusCode.NotFound; + continue; + } + + // All this double-copying of data is needed for setting the ContentLength tag + + // Copy to temporary MemoryStream + await using var memoryStream = new MemoryStream(); + await assetStream.CopyToAsync(memoryStream, cancellationToken); + await memoryStream.FlushAsync(cancellationToken); + memoryStream.Position = 0L; + + // Set the ContentLength tag + response.ContentLength64 = memoryStream.Length; + + // Copy back to the OutputStream + await memoryStream.CopyToAsync(response.OutputStream, cancellationToken); + await response.OutputStream.FlushAsync(cancellationToken); + response.StatusCode = (int)HttpStatusCode.OK; + response.StatusDescription = "OK"; + +#endif + } + } + catch (Exception ex) + { + var title = "Internal Server Error"; + var message = WebUtility.HtmlEncode(ex.Message); + var stackTrace = WebUtility.HtmlEncode(ex.StackTrace ?? ""); + + var html = $$""" + + + + + {{title}} + + + +

{{title}}

+

{{message}}

+
{{stackTrace}}
+ + + """; + + try + { + var buffer = Encoding.UTF8.GetBytes(html); + response.StatusCode = (int)HttpStatusCode.InternalServerError; + response.ContentType = "text/html"; + response.ContentLength64 = buffer.Length; + await response.OutputStream.WriteAsync(buffer, cancellationToken); + } + catch (Exception) { } + } + finally + { + response.Close(); + } + } + } + catch (Exception ex) + { + _ = ex; + } + } + } + + /// + public void Dispose() + { + _disposed = true; + _fileStream.Dispose(); + _httpListener.Abort(); + } + + private static int GetAvailablePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + return port; + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/VideoStreamServer.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/VideoStreamServer.cs deleted file mode 100644 index 794d66bdc..000000000 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/VideoStreamServer.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using SecureFolderFS.Shared.ComponentModel; - -namespace SecureFolderFS.Maui.AppModels -{ - internal sealed class VideoStreamServer : IAsyncInitialize, IDisposable - { - private readonly HttpListener _httpListener; - private readonly Stream _videoStream; - private readonly string _mimeType; - private readonly int _port; - private bool _disposed; - - public string BaseAddress => $"http://localhost:{_port}/video"; - - public VideoStreamServer(Stream videoStream, string mimeType) - { - _videoStream = videoStream; - _mimeType = mimeType; - - // Automatically find a free port - _port = GetAvailablePort(); - - // Initialize the HttpListener with the free port - _httpListener = new HttpListener(); - _httpListener.Prefixes.Add($"http://localhost:{_port}/"); - } - - /// - public Task InitAsync(CancellationToken cancellationToken = default) - { - _httpListener.Start(); - _ = BeginListeningAsync(); - - return Task.CompletedTask; - - async Task BeginListeningAsync() - { - while (!_disposed && _httpListener.IsListening && await _httpListener.GetContextAsync() is var context) - { - try - { - if (context.Request.RawUrl != "/video") - { - context.Response.StatusCode = (int)HttpStatusCode.NotFound; - context.Response.Close(); - continue; - } - - context.Response.StatusCode = (int)HttpStatusCode.OK; - context.Response.ContentType = _mimeType; - context.Response.ContentLength64 = _videoStream.Length; - context.Response.Headers["Accept-Ranges"] = "bytes"; - - await _videoStream.CopyToAsync(context.Response.OutputStream, 64 * 1024, cancellationToken); - _videoStream.Position = 0L; - break; - } - catch (Exception) - { - break; - } - } - } - } - - /// - public void Dispose() - { - _disposed = true; - _videoStream.Dispose(); - _httpListener.Abort(); - } - - private static int GetAvailablePort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - - return port; - } - } -} diff --git a/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs index 76aa8d21c..b055c874e 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs @@ -1,11 +1,12 @@ using SecureFolderFS.Maui.Views.Vault; +using SecureFolderFS.Sdk.AppModels; using SecureFolderFS.Sdk.ViewModels; namespace SecureFolderFS.Maui { public partial class AppShell : Shell { - public MainViewModel MainViewModel { get; } = new(); + public MainViewModel MainViewModel { get; } = new(new VaultCollectionModel()); public AppShell() { @@ -15,6 +16,7 @@ public AppShell() Routing.RegisterRoute("LoginPage", typeof(LoginPage)); Routing.RegisterRoute("OverviewPage", typeof(OverviewPage)); Routing.RegisterRoute("BrowserPage", typeof(BrowserPage)); + Routing.RegisterRoute("HealthPage", typeof(HealthPage)); } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/IocExtensions.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/IocExtensions.cs index e156409ce..a73063c16 100644 --- a/src/Platforms/SecureFolderFS.Maui/Extensions/IocExtensions.cs +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/IocExtensions.cs @@ -1,9 +1,14 @@ using OwlCore.Storage; +using Plugin.Maui.BottomSheet.Hosting; +using Plugin.Maui.BottomSheet.Navigation; using SecureFolderFS.Maui.ServiceImplementation; using SecureFolderFS.Maui.ServiceImplementation.Settings; +using SecureFolderFS.Maui.Sheets; using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.UI.ServiceImplementation; using SecureFolderFS.UI.ServiceImplementation.Settings; +using AddService = Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions; namespace SecureFolderFS.Maui.Extensions { @@ -12,12 +17,15 @@ internal static class IocExtensions public static IServiceCollection WithMauiServices(this IServiceCollection serviceCollection, IModifiableFolder settingsFolder) { return serviceCollection - .AddSingleton(_ => new(new MauiAppSettings(settingsFolder), new UserSettings(settingsFolder))) - .AddSingleton() - .AddSingleton() - .AddTransient() - .AddSingleton() - //.AddSingleton() + .Foundation(AddService.AddSingleton, _ => new(new MauiAppSettings(settingsFolder), new UserSettings(settingsFolder))) + .Foundation(AddService.AddSingleton) + .Foundation(AddService.AddSingleton) + .Foundation(AddService.AddSingleton) + .Foundation(AddService.AddSingleton) + .Foundation(AddService.AddSingleton) + .Foundation(AddService.AddTransient) + + .AddBottomSheet(nameof(ViewOptionsSheet)) ; } } diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs new file mode 100644 index 000000000..0ce7d7ac8 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs @@ -0,0 +1,48 @@ +using Microsoft.Maui.Handlers; +using Microsoft.Maui.Platform; +using SecureFolderFS.Maui.Helpers; +using SecureFolderFS.Maui.UserControls.Common; +using SecureFolderFS.UI.Enums; +#if ANDROID +using Android.Graphics.Drawables.Shapes; +using Paint = Android.Graphics.Paint; +using ShapeDrawable = Android.Graphics.Drawables.ShapeDrawable; +#endif + +namespace SecureFolderFS.Maui.Extensions.Mappers +{ + public static partial class CustomMappers + { + public static void AddEntryMappers() + { + EntryHandler.Mapper.AppendToMapping($"{nameof(CustomMappers)}.{nameof(Entry)}", (handler, view) => + { + if (view is not ModernEntry) + return; +#if ANDROID + const float R = 24f; + var outerRadii = new[] { R, R, R, R, R, R, R, R }; + var roundRectShape = new RoundRectShape(outerRadii, null, null); + var shape = new ShapeDrawable(roundRectShape); + + shape.Paint!.Color = (App.Instance.Resources[MauiThemeHelper.Instance.CurrentTheme switch + { + ThemeType.Dark => "BorderDarkColor", + _ => "BorderLightColor" + }] as Color)!.ToPlatform(); + shape.Paint.StrokeWidth = 4; + shape.Paint.SetStyle(Paint.Style.Stroke); + handler.PlatformView.Background = shape; + handler.PlatformView.SetPadding(40, 32,40, 32); +#elif IOS + handler.PlatformView.Layer.BorderColor = (App.Current.Resources[MauiThemeHelper.Instance.CurrentTheme switch + { + ThemeType.Dark => "BorderDarkColor", + _ => "BorderLightColor" + }] as Color).ToPlatform().CGColor; + handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.RoundedRect; +#endif + }); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Label.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Label.cs new file mode 100644 index 000000000..6a996d42f --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Label.cs @@ -0,0 +1,21 @@ +using Microsoft.Maui.Handlers; +using SecureFolderFS.Maui.UserControls.Common; + +namespace SecureFolderFS.Maui.Extensions.Mappers +{ + public static partial class CustomMappers + { + public static void AddLabelMappers() + { + LabelHandler.Mapper.AppendToMapping($"{nameof(CustomMappers)}.{nameof(Label)}", (handler, view) => + { + if (view is not SelectableLabel) + return; + +#if ANDROID + handler.PlatformView.SetTextIsSelectable(true); +#endif + }); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs new file mode 100644 index 000000000..af7eec791 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs @@ -0,0 +1,45 @@ +using Microsoft.Maui.Handlers; +using Microsoft.Maui.Platform; +using SecureFolderFS.Maui.UserControls.Common; +using SecureFolderFS.UI.Enums; +#if ANDROID +using Android.Graphics.Drawables.Shapes; +using SecureFolderFS.Maui.Helpers; +using Paint = Android.Graphics.Paint; +using ShapeDrawable = Android.Graphics.Drawables.ShapeDrawable; +#endif + +namespace SecureFolderFS.Maui.Extensions.Mappers +{ + public static partial class CustomMappers + { + public static void AddPickerMappers() + { + PickerHandler.Mapper.AppendToMapping($"{nameof(CustomMappers)}.{nameof(Picker)}", (handler, view) => + { + if (view is not ModernPicker modernPicker) + return; + +#if ANDROID + const float R = 24f; + var outerRadii = new[] { R, R, R, R, R, R, R, R }; + var roundRectShape = new RoundRectShape(outerRadii, null, null); + var shape = new ShapeDrawable(roundRectShape); + + shape.Paint!.Color = modernPicker.IsTransparent + ? Colors.Transparent.ToPlatform() + : (App.Instance.Resources["ThemeSecondaryColorBrush"] as SolidColorBrush)!.Color.ToPlatform(); + shape.Paint.StrokeWidth = 0; + shape.Paint.SetStyle(Paint.Style.FillAndStroke); + handler.PlatformView.SetTextColor((App.Instance.Resources[MauiThemeHelper.Instance.CurrentTheme switch + { + ThemeType.Dark => "QuarternaryDarkColor", + _ => "QuarternaryLightColor" + }] as Color)!.ToPlatform()); + handler.PlatformView.Background = shape; + handler.PlatformView.SetPadding(32, 24, 32, 24); +#endif + }); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Helpers/DeferredInitialization.cs b/src/Platforms/SecureFolderFS.Maui/Helpers/DeferredInitialization.cs new file mode 100644 index 000000000..0caf1a4df --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Helpers/DeferredInitialization.cs @@ -0,0 +1,81 @@ +using SecureFolderFS.Shared.ComponentModel; + +namespace SecureFolderFS.Maui.Helpers +{ + internal sealed class DeferredInitialization : IDisposable + where T : notnull + { + private T? _context; + private bool _isProcessing; + private readonly List _initializations = new(); + private readonly SemaphoreSlim _semaphore = new(1, 1); + + public void SetContext(T context) + { + if (context.Equals(_context)) + return; + + _context = context; + lock (_initializations) + _initializations.Clear(); + } + + public void Enqueue(IAsyncInitialize asyncInitialize) + { + if (_context is null) + return; + + lock (_initializations) + _initializations.Add(asyncInitialize); + + _ = StartProcessingAsync(); + } + + private async Task StartProcessingAsync() + { + await _semaphore.WaitAsync(); + + try + { + if (_isProcessing) + return; + + _isProcessing = true; + } + finally + { + _semaphore.Release(); + } + + try + { + while (true) + { + IAsyncInitialize[] batch; + lock (_initializations) + { + if (_initializations.Count == 0) + break; + + batch = _initializations.Take(UI.Constants.Browser.THUMBNAIL_MAX_PARALLELISATION).ToArray(); + _initializations.RemoveRange(0, batch.Length); + } + + var tasks = batch.Select(init => init.InitAsync()); + await Task.Run(async () => await Task.WhenAll(tasks)); + } + } + finally + { + _isProcessing = false; + } + } + + /// + public void Dispose() + { + _initializations.Clear(); + _semaphore.Dispose(); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Helpers/MauiThemeHelper.cs b/src/Platforms/SecureFolderFS.Maui/Helpers/MauiThemeHelper.cs new file mode 100644 index 000000000..81929caec --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Helpers/MauiThemeHelper.cs @@ -0,0 +1,22 @@ +using SecureFolderFS.UI.Helpers; + +namespace SecureFolderFS.Maui.Helpers +{ + /// + internal sealed class MauiThemeHelper : ThemeHelper + { + /// + /// Gets the singleton instance of . + /// + public static MauiThemeHelper Instance { get; } = new(); + + /// + protected override void UpdateTheme() + { + MainThread.BeginInvokeOnMainThread(() => + { + App.Instance.UserAppTheme = (AppTheme)(int)CurrentTheme; + }); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Localization/ResourceString.cs b/src/Platforms/SecureFolderFS.Maui/Localization/ResourceString.cs index b96c46b33..c8b755050 100644 --- a/src/Platforms/SecureFolderFS.Maui/Localization/ResourceString.cs +++ b/src/Platforms/SecureFolderFS.Maui/Localization/ResourceString.cs @@ -5,6 +5,7 @@ namespace SecureFolderFS.Maui.Localization { [EditorBrowsable(EditorBrowsableState.Never)] + [AcceptEmptyServiceProvider] internal sealed class ResourceString : IMarkupExtension { private static ILocalizationService? LocalizationService { get; set; } diff --git a/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs b/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs index 93b27c8a9..5e9376633 100644 --- a/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs +++ b/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs @@ -1,10 +1,8 @@ using APES.UI.XF; using CommunityToolkit.Maui; -using Microsoft.Extensions.Logging; -using The49.Maui.BottomSheet; - +using LibVLCSharp.MAUI; +using Plugin.Maui.BottomSheet.Hosting; #if ANDROID -using Material.Components.Maui.Extensions; using MauiIcons.Material; #elif IOS using MauiIcons.Cupertino; @@ -28,16 +26,16 @@ public static MauiApp CreateMauiApp() // Plugins .UseMauiCommunityToolkitMediaElement() // https://github.com/CommunityToolkit/Maui .UseMauiCommunityToolkit() // https://github.com/CommunityToolkit/Maui - .UseBottomSheet() // https://github.com/the49ltd/The49.Maui.BottomSheet + .UseBottomSheet() // https://github.com/lucacivale/Maui.BottomSheet .ConfigureContextMenuContainer() // https://github.com/anpin/ContextMenuContainer + .UseLibVLCSharp() // https://github.com/videolan/libvlcsharp #if ANDROID .UseMaterialMauiIcons() // https://github.com/AathifMahir/MauiIcons - .UseMaterialComponents() // https://github.com/mdc-maui/mdc-maui #elif IOS .UseCupertinoMauiIcons() #endif - + // Handlers .ConfigureMauiHandlers(handlers => { @@ -45,7 +43,7 @@ public static MauiApp CreateMauiApp() handlers.AddHandler(); #endif }) - + ; // Finish initialization return builder.Build(); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml index 19750b79f..444981d92 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml @@ -1,6 +1,10 @@  - + @@ -16,5 +20,6 @@ + diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/BiometricPromptCallback.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/BiometricPromptCallback.cs new file mode 100644 index 000000000..a43df0c62 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/BiometricPromptCallback.cs @@ -0,0 +1,41 @@ +using Android.Hardware.Biometrics; +using Java.Lang; + +namespace SecureFolderFS.Maui.AppModels +{ + /// + internal sealed class BiometricPromptCallback : BiometricPrompt.AuthenticationCallback + { + private readonly Action? _onSuccess; + private readonly Action? _onError; + private readonly Action? _onFailure; + + public BiometricPromptCallback( + Action? onSuccess, + Action? onError, + Action? onFailure) + { + _onSuccess = onSuccess; + _onError = onError; + _onFailure = onFailure; + } + + /// + public override void OnAuthenticationSucceeded(BiometricPrompt.AuthenticationResult? result) + { + _onSuccess?.Invoke(result); + } + + /// + public override void OnAuthenticationError(BiometricErrorCode errorCode, ICharSequence? errString) + { + _onError?.Invoke(errorCode, errString?.ToString()); + } + + /// + public override void OnAuthenticationFailed() + { + _onFailure?.Invoke(); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/DialogOnClickListener.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/DialogOnClickListener.cs new file mode 100644 index 000000000..7876743dc --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/DialogOnClickListener.cs @@ -0,0 +1,21 @@ +using Android.Content; + +namespace SecureFolderFS.Maui.AppModels +{ + /// + internal sealed class DialogOnClickListener : Java.Lang.Object, IDialogInterfaceOnClickListener + { + private readonly Action _onClick; + + public DialogOnClickListener(Action onClick) + { + _onClick = onClick; + } + + /// + public void OnClick(IDialogInterface? dialog, int which) + { + _onClick.Invoke(dialog, which); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/StreamedMediaSource.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/StreamedMediaSource.cs new file mode 100644 index 000000000..bff7afcbf --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/StreamedMediaSource.cs @@ -0,0 +1,35 @@ +using Android.Media; +using Stream = System.IO.Stream; + +namespace SecureFolderFS.Maui.AppModels +{ + /// + internal sealed class StreamedMediaSource : MediaDataSource + { + private readonly Stream _sourceStream; + + /// + public override long Size => _sourceStream.Length; + + public StreamedMediaSource(Stream sourceStream) + { + _sourceStream = sourceStream; + } + + /// + public override int ReadAt(long position, byte[]? buffer, int offset, int size) + { + if (buffer is null) + return 0; + + _sourceStream.Position = position; + return _sourceStream.Read(buffer, offset, size); + } + + /// + public override void Close() + { + _sourceStream.Dispose(); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Assets/pdfjs53.zip b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Assets/pdfjs53.zip new file mode 100644 index 000000000..107b9009b Binary files /dev/null and b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Assets/pdfjs53.zip differ diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidBiometricHelpers.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidBiometricHelpers.cs new file mode 100644 index 000000000..e79b2cb1e --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidBiometricHelpers.cs @@ -0,0 +1,32 @@ +using Android.Security.Keystore; +using Java.Security; + +namespace SecureFolderFS.Maui.Platforms.Android.Helpers +{ + internal static class AndroidBiometricHelpers + { + public static KeyPairGenerator? GetKeyPairGenerator(string keyName, string keyStoreProvider) + { + var keyPairGenerator = KeyPairGenerator.GetInstance(KeyProperties.KeyAlgorithmRsa, keyStoreProvider); + if (keyPairGenerator is null) + return null; + + var builder = new KeyGenParameterSpec.Builder( + keyName, + KeyStorePurpose.Sign) + .SetDigests(KeyProperties.DigestSha256) + .SetSignaturePaddings(KeyProperties.SignaturePaddingRsaPkcs1) + .SetUserAuthenticationRequired(true) + .SetInvalidatedByBiometricEnrollment(false); + + keyPairGenerator.Initialize(builder.Build()); + return keyPairGenerator; + } + + public static byte[]? SignData(Signature signature, byte[] data) + { + signature.Update(data); + return signature.Sign(); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs index 6d111f2fa..8f527955f 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs @@ -1,12 +1,13 @@ -using CommunityToolkit.Maui.Alerts; -using CommunityToolkit.Maui.Core; using OwlCore.Storage; using OwlCore.Storage.System.IO; using SecureFolderFS.Maui.Extensions; using SecureFolderFS.Maui.Platforms.Android.ServiceImplementation; using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.UI; using SecureFolderFS.UI.Helpers; using SecureFolderFS.UI.ServiceImplementation; +using AddService = Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions; namespace SecureFolderFS.Maui.Platforms.Android.Helpers { @@ -14,13 +15,13 @@ namespace SecureFolderFS.Maui.Platforms.Android.Helpers internal sealed class AndroidLifecycleHelper : BaseLifecycleHelper { /// - protected override string AppDirectory { get; } = Microsoft.Maui.Storage.FileSystem.Current.AppDataDirectory; + protected override string AppDirectory { get; } = FileSystem.Current.AppDataDirectory; /// public override Task InitAsync(CancellationToken cancellationToken = default) { // Initialize settings - var settingsFolderPath = Path.Combine(AppDirectory, SecureFolderFS.UI.Constants.FileNames.SETTINGS_FOLDER_NAME); + var settingsFolderPath = Path.Combine(AppDirectory, Constants.FileNames.SETTINGS_FOLDER_NAME); var settingsFolder = new SystemFolder(Directory.CreateDirectory(settingsFolderPath)); ConfigureServices(settingsFolder); @@ -37,58 +38,19 @@ public override void LogExceptionToFile(Exception? ex) protected override IServiceCollection ConfigureServices(IModifiableFolder settingsFolder) { return base.ConfigureServices(settingsFolder) - //.AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - + //.Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) + .WithMauiServices(settingsFolder) ; } - - private async Task RequestPermissionsAsync() - where TPermission : Permissions.BasePermission, new() - { - var permissionStatus = await Permissions.CheckStatusAsync(); - switch (permissionStatus) - { - case PermissionStatus.Denied: - if (Permissions.ShouldShowRationale()) - { - await Shell.Current.DisplayAlert("Action required", - "For SecureFolderFS to function correctly, you'll need to grant the storage permission.", - "Ok"); - } - - if (await Permissions.RequestAsync() != PermissionStatus.Granted) - { - await Toast.Make("Storage permissions are required for SecureFolderFS", ToastDuration.Long).Show(); - Application.Current?.Quit(); - } - - return; - - case PermissionStatus.Disabled: - case PermissionStatus.Restricted: - await Toast.Make("Storage permissions are required for SecureFolderFS", ToastDuration.Long).Show(); - Application.Current?.Quit(); - return; - - case PermissionStatus.Granted: - return; - - default: - case PermissionStatus.Limited: - case PermissionStatus.Unknown: - return; - } - } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs index f55b81860..84b675fef 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs @@ -1,6 +1,12 @@ +using System.ComponentModel; using Android.App; using Android.Content; using Android.Content.PM; +using Android.OS; +using AndroidX.Activity; +using Microsoft.Maui.Platform; +using SecureFolderFS.Maui.Helpers; +using SecureFolderFS.UI.Enums; namespace SecureFolderFS.Maui { @@ -22,11 +28,45 @@ public MainActivity() Instance ??= this; } + /// + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + // Enable edge to edge + EdgeToEdge.Enable(this); + + // Configure StatusBar color + ApplyStatusBarColor(MauiThemeHelper.Instance.CurrentTheme); + + // Always listen for theme changes + MauiThemeHelper.Instance.PropertyChanged += ThemeHelper_PropertyChanged; + } + /// protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data) { base.OnActivityResult(requestCode, resultCode, data); ActivityResult?.Invoke(requestCode, resultCode, data); } + + private void ThemeHelper_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(MauiThemeHelper.CurrentTheme)) + return; + + ApplyStatusBarColor(MauiThemeHelper.Instance.CurrentTheme); + } + + private void ApplyStatusBarColor(ThemeType themeType) + { +#pragma warning disable CA1422 + Window?.SetStatusBarColor((App.Instance.Resources[themeType switch + { + ThemeType.Dark => "PrimaryDarkColor", + _ => "PrimaryLightColor" + }] as Color)!.ToPlatform()); +#pragma warning restore CA1422 + } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Resources/values/colors.xml b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Resources/values/colors.xml index 491dfad4d..01e1a4a59 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Resources/values/colors.xml +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Resources/values/colors.xml @@ -1,6 +1,6 @@ - #32608d - #1791ff - #106bbf + #1791FF + #106bbf + #1375d1 diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Resources/values/styles.xml b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Resources/values/styles.xml deleted file mode 100644 index 49937c5ed..000000000 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Resources/values/styles.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Resources/xml/network_security_config.xml b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Resources/xml/network_security_config.xml new file mode 100644 index 000000000..d4950ca4e --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Resources/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + localhost + + diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidApplicationService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidApplicationService.cs index dada41b18..a9cb589d8 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidApplicationService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidApplicationService.cs @@ -13,7 +13,7 @@ internal sealed class AndroidApplicationService : BaseApplicationService /// public override string Platform { get; } = "Android - MAUI"; - + /// public override Task OpenUriAsync(Uri uri) { @@ -30,11 +30,11 @@ public override Task OpenUriAsync(Uri uri) return Task.CompletedTask; } - + /// public override string GetSystemVersion() { - return DeviceInfo.VersionString; + return $"Android {DeviceInfo.VersionString}"; } /// diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs index 00506ff75..63cc85c5b 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs @@ -1,12 +1,13 @@ using System.Web; -using CommunityToolkit.Maui.Core.Extensions; -using CommunityToolkit.Maui.Storage; -using OwlCore.Storage; -using SecureFolderFS.Sdk.Services; using Android.App; using Android.Content; using Android.Provider; +using CommunityToolkit.Maui.Core.Extensions; +using CommunityToolkit.Maui.Storage; +using OwlCore.Storage; using SecureFolderFS.Maui.Platforms.Android.Storage; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Storage.Pickers; using AndroidUri = Android.Net.Uri; using AOSEnvironment = Android.OS.Environment; @@ -16,23 +17,7 @@ namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation internal sealed class AndroidFileExplorerService : IFileExplorerService { /// - public Task TryOpenInFileExplorerAsync(IFolder folder, CancellationToken cancellationToken = default) - { - // TODO: Try to implement opening in android file explorer - return Task.CompletedTask; - } - - /// - public async Task SaveFileAsync(string suggestedName, Stream dataStream, IDictionary? filter, CancellationToken cancellationToken = default) - { - var fileSaver = FileSaver.Default; - var result = await fileSaver.SaveAsync(suggestedName, dataStream, cancellationToken); - - return result.IsSuccessful; - } - - /// - public async Task PickFileAsync(IEnumerable? filter, bool persist = true, CancellationToken cancellationToken = default) + public async Task PickFileAsync(PickerOptions? options, bool offerPersistence = true, CancellationToken cancellationToken = default) { var intent = new Intent(Intent.ActionOpenDocument) .AddCategory(Intent.CategoryOpenable) @@ -41,20 +26,18 @@ public async Task SaveFileAsync(string suggestedName, Stream dataStream, I var pickerIntent = Intent.CreateChooser(intent, "Select file"); + // TODO: Determine if GrantReadUriPermission and GrantWriteUriPermission are needed for access persistence + // FilePicker 0x2AF9 var result = await StartActivityAsync(pickerIntent, 0x2AF9); if (result is null || MainActivity.Instance is null) return null; - var file = new AndroidFile(result, MainActivity.Instance); - if (persist) - await file.AddBookmarkAsync(cancellationToken); - - return file; + return new AndroidFile(result, MainActivity.Instance); } /// - public async Task PickFolderAsync(bool persist = true, CancellationToken cancellationToken = default) + public async Task PickFolderAsync(PickerOptions? options, bool offerPersistence = true, CancellationToken cancellationToken = default) { var initialPath = AndroidPathExtensions.GetExternalDirectory(); if (AOSEnvironment.ExternalStorageDirectory is not null) @@ -64,7 +47,7 @@ public async Task SaveFileAsync(string suggestedName, Stream dataStream, I var intent = new Intent(Intent.ActionOpenDocumentTree); intent.PutExtra(DocumentsContract.ExtraInitialUri, initialFolderUri); - if (persist) + if (offerPersistence) { intent.AddFlags(ActivityFlags.GrantReadUriPermission); intent.AddFlags(ActivityFlags.GrantWriteUriPermission); @@ -75,11 +58,40 @@ public async Task SaveFileAsync(string suggestedName, Stream dataStream, I if (result is null || MainActivity.Instance is null) return null; - var folder = new AndroidFolder(result, MainActivity.Instance); - if (persist) - await folder.AddBookmarkAsync(cancellationToken); + return new AndroidFolder(result, MainActivity.Instance); + } - return folder; + /// + public Task TryOpenInFileExplorerAsync(IFolder folder, CancellationToken cancellationToken = default) + { + try + { + var context = MainActivity.Instance; + if (context is null) + return Task.CompletedTask; + + var intent = new Intent(Intent.ActionView); + intent.SetType("*/*"); + intent.AddCategory(Intent.CategoryDefault); + intent.AddFlags(ActivityFlags.NewTask); + context.StartActivity(intent); + + return Task.CompletedTask; + } + catch (Exception ex) + { + _ = ex; + return Task.CompletedTask; + } + } + + /// + public async Task SaveFileAsync(string suggestedName, Stream dataStream, IDictionary? filter, CancellationToken cancellationToken = default) + { + var fileSaver = FileSaver.Default; + var result = await fileSaver.SaveAsync(suggestedName, dataStream, cancellationToken); + + return result.IsSuccessful; } private async Task StartActivityAsync(Intent? pickerIntent, int requestCode) diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs new file mode 100644 index 000000000..014f23153 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs @@ -0,0 +1,59 @@ +using Android.Media; +using OwlCore.Storage; +using SecureFolderFS.Core.MobileFS.Platforms.Android.Helpers; +using SecureFolderFS.Maui.AppModels; +using SecureFolderFS.Maui.ServiceImplementation; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Enums; +using SecureFolderFS.Shared.Helpers; +using SecureFolderFS.UI; +using Stream = System.IO.Stream; + +namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation +{ + /// + internal sealed class AndroidMediaService : BaseMauiMediaService + { + /// + public override async Task GenerateThumbnailAsync(IFile file, TypeHint typeHint = default, CancellationToken cancellationToken = default) + { + if (typeHint == default) + typeHint = FileTypeHelper.GetTypeHint(file); + + switch (typeHint) + { + case TypeHint.Image: + { + await using var stream = await file.OpenReadAsync(cancellationToken).ConfigureAwait(false); + var imageStream = await ThumbnailHelpers.GenerateImageThumbnailAsync(stream, Constants.Browser.IMAGE_THUMBNAIL_MAX_SIZE).ConfigureAwait(false); + + return new ImageStream(imageStream); + } + + case TypeHint.Media: + { + await using var stream = await file.OpenReadAsync(cancellationToken).ConfigureAwait(false); + var imageStream = await GenerateVideoThumbnailAsync(stream, TimeSpan.FromSeconds(0)).ConfigureAwait(false); + + return new ImageStream(imageStream); + } + + default: throw new InvalidOperationException("The provided file type is invalid."); + } + } + + private static async Task GenerateVideoThumbnailAsync(Stream stream, TimeSpan captureTime) + { + using var retriever = new MediaMetadataRetriever(); + await retriever.SetDataSourceAsync(new StreamedMediaSource(stream)).ConfigureAwait(false); + + // Use scaled frame for efficiency + using var bitmap = retriever.GetScaledFrameAtTime(captureTime.Ticks, Option.ClosestSync, 320, 240); + if (bitmap is null) + throw new NotSupportedException("Could not retrieve scaled frame."); + + return await ThumbnailHelpers.CompressBitmapAsync(bitmap).ConfigureAwait(false); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs index 0b71db01b..d1e381cd1 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs @@ -1,3 +1,5 @@ +using OwlCore.Storage; +using SecureFolderFS.Maui.Platforms.Android.Storage; using SecureFolderFS.Sdk.Services; namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation @@ -6,8 +8,47 @@ namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation internal sealed class AndroidSystemService : ISystemService { // TODO: Use BroadcastReceiver - ActionScreenOff, ActionUserPresent - + + /// + public event EventHandler? DeviceLocked; + /// - public event EventHandler? DesktopLocked; + public Task GetAvailableFreeSpaceAsync(IFolder storageRoot, CancellationToken cancellationToken = default) + { +#if ANDROID + if (storageRoot is not AndroidFolder androidFolder) + return Task.FromException(new ArgumentNullException(nameof(storageRoot))); + + // fallback: if we can't resolve, assume /storage/emulated/0 + var realPath = TryGetRealPath(androidFolder) ?? "/storage/emulated/0"; + var stat = new global::Android.OS.StatFs(realPath); + + long availableBytes; +#if ANDROID33_0_OR_GREATER + availableBytes = stat.AvailableBytes; +#else + availableBytes = (long)stat.BlockSizeLong * (long)stat.AvailableBlocksLong; +#endif + + return Task.FromResult(availableBytes); + + static string? TryGetRealPath(AndroidFolder androidFolder) + { + if (androidFolder.Inner is not { Scheme: "content", Authority: "com.android.externalstorage.documents" }) + return null; // Couldn't resolve + + var split = androidFolder.Inner.Path?.Split(':'); + var relativePath = string.Join('/', split?.Skip(1).Take(Range.All) ?? []); + var type = split?.FirstOrDefault(); + if (type?.Contains("primary", StringComparison.OrdinalIgnoreCase) ?? true) + return $"/storage/emulated/0/{relativePath}"; + + return $"/storage/{type}/{relativePath}"; + } +#else + throw new PlatformNotSupportedException("Only implemented on Android."); +#endif + } + } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs index 89360c239..4cebf3485 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs @@ -2,6 +2,7 @@ using OwlCore.Storage; using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.VaultAccess; +using SecureFolderFS.Maui.Platforms.Android.ViewModels; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.ViewModels.Controls.Authentication; using SecureFolderFS.Shared.Models; @@ -22,6 +23,10 @@ public override IEnumerable GetContentCiphers() // - https://nsec.rocks/docs/install#supported-platforms yield return Constants.CipherId.AES_GCM; + +#if DEBUG + yield return Constants.CipherId.NONE; +#endif } /// @@ -29,14 +34,15 @@ public override async IAsyncEnumerable GetLoginAsync(IF { var vaultReader = new VaultReader(vaultFolder, StreamSerializer.Instance); var config = await vaultReader.ReadConfigurationAsync(cancellationToken); - var authenticationMethods = config.AuthenticationMethod.Split(Core.Constants.Vault.Authentication.SEPARATOR); + var authenticationMethod = AuthenticationMethod.FromString(config.AuthenticationMethod); - foreach (var item in authenticationMethods) + foreach (var item in authenticationMethod.Methods) { yield return item switch { Core.Constants.Vault.Authentication.AUTH_PASSWORD => new PasswordLoginViewModel(), Core.Constants.Vault.Authentication.AUTH_KEYFILE => new KeyFileLoginViewModel(vaultFolder), + Core.Constants.Vault.Authentication.AUTH_ANDROID_BIOMETRIC => new AndroidBiometricLoginViewModel(vaultFolder, config.Uid), _ => throw new NotSupportedException($"The authentication method '{item}' is not supported by the platform.") }; } @@ -49,6 +55,9 @@ public override async IAsyncEnumerable GetCreationAsync // Password yield return new PasswordCreationViewModel(); + // Android Biometric + yield return new AndroidBiometricCreationViewModel(vaultFolder, vaultId); + // Key File yield return new KeyFileCreationViewModel(vaultId); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs index 2d06088bf..a5888f02e 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs @@ -1,8 +1,8 @@ using Android.App; using Android.Content; using Android.Provider; -using Android.Runtime; using AndroidX.DocumentFile.Provider; +using Java.IO; using OwlCore.Storage; using SecureFolderFS.Core.MobileFS.Platforms.Android.Streams; using SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties; @@ -30,10 +30,9 @@ public AndroidFile(AndroidUri uri, Activity activity, AndroidFolder? parent = nu /// public Task OpenStreamAsync(FileAccess accessMode, CancellationToken cancellationToken = default) { - Stream? stream; if (IsVirtualFile(activity, Inner)) { - stream = GetVirtualFileStream(activity, Inner, accessMode != FileAccess.Read); + var stream = GetVirtualFileStream(activity, Inner, accessMode != FileAccess.Read); if (stream is null) return Task.FromException(new ArgumentException("No stream types available for '*/*' mime type.")); @@ -42,25 +41,27 @@ public Task OpenStreamAsync(FileAccess accessMode, CancellationToken can if (accessMode == FileAccess.Read) { - stream = activity.ContentResolver?.OpenInputStream(Inner); + var inStream = activity.ContentResolver?.OpenInputStream(Inner); + if (inStream is null) + return Task.FromException(new UnauthorizedAccessException("Could not open input stream.")); + + return Task.FromResult(inStream); } else { - var inputStream = (activity.ContentResolver?.OpenInputStream(Inner) as InputStreamInvoker)?.BaseInputStream; - var outputStream = (activity.ContentResolver?.OpenOutputStream(Inner) as OutputStreamInvoker)?.BaseOutputStream; - if (inputStream is null || outputStream is null) - return Task.FromException(new UnauthorizedAccessException($"Could not open a stream to: '{Id}'.")); + var fd = activity.ContentResolver?.OpenFileDescriptor(Inner, "rwt"); + var fInChannel = new FileInputStream(fd.FileDescriptor).Channel; + var fOutChannel = new FileOutputStream(fd.FileDescriptor).Channel; - var combinedInputStream = new InputOutputStream(inputStream, outputStream, GetFileSize(activity.ContentResolver, Inner)); - stream = combinedInputStream; - } - - if (stream is null) - return Task.FromException(new UnauthorizedAccessException($"Could not open a stream to: '{Id}'.")); + if (fInChannel is null || fOutChannel is null) + return Task.FromException( + new ArgumentException("Could not open input and output streams.")); - return Task.FromResult(stream); + var channelledStream = new ChannelledStream(fInChannel, fOutChannel); + return Task.FromResult(channelledStream); + } } - + /// public override Task GetPropertiesAsync() { @@ -90,47 +91,16 @@ private static bool IsVirtualFile(Context context, AndroidUri uri) private static Stream? GetVirtualFileStream(Context context, AndroidUri uri, bool isOutput) { var mimeTypes = context.ContentResolver?.GetStreamTypes(uri, "*/*"); - 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; - } + if (!(mimeTypes?.Length >= 1)) + return null; - return null; - } - - private static long GetFileSize(ContentResolver? contentResolver, AndroidUri uri) - { - if (contentResolver is null) - return 0L; - - try - { - // Try to get file size using content resolver - using var cursor = contentResolver.Query(uri, null, null, null, null); - if (cursor != null && cursor.MoveToFirst()) - { - int sizeIndex = cursor.GetColumnIndex(IOpenableColumns.Size); - if (sizeIndex != -1) - { - return cursor.GetLong(sizeIndex); - } - } - } - catch - { - // Fallback method if content resolver fails - } + var mimeType = mimeTypes[0]; + var asset = context.ContentResolver!.OpenTypedAssetFileDescriptor(uri, mimeType, null); + var stream = isOutput + ? asset?.CreateOutputStream() + : asset?.CreateInputStream(); - // If size can't be determined, return -1 - return -1; + return stream; } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs index eb88cd176..a2f757599 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs @@ -2,7 +2,6 @@ using Android.Provider; using Android.Webkit; using AndroidX.DocumentFile.Provider; -using AndroidX.Navigation; using OwlCore.Storage; using SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties; using SecureFolderFS.Storage.Renamable; @@ -16,10 +15,10 @@ namespace SecureFolderFS.Maui.Platforms.Android.Storage internal sealed class AndroidFolder : AndroidStorable, IModifiableFolder, IChildFolder, IGetFirstByName, IRenamableFolder // TODO: Implement: IGetFirstByName, IGetItem { private static Exception RenameException { get; } = new IOException("Could not rename the item."); - + /// public override string Name { get; } - + /// public override DocumentFile? Document { get; } @@ -154,6 +153,8 @@ public Task CreateFileAsync(string name, bool overwrite = false, Can if (overwrite && existingFile is not null) existingFile.Delete(); + else if (existingFile is not null) + return Task.FromResult(new AndroidFile(existingFile.Uri, activity, this, permissionRoot)); var newFile = Document?.CreateFile(mimeType, name); if (newFile is null) @@ -186,14 +187,14 @@ public async Task GetFirstByNameAsync(string name, CancellationT return target; } - + /// public override Task GetPropertiesAsync() { if (Document is null) return Task.FromException(new ArgumentNullException(nameof(Document))); - properties ??= new AndroidFileProperties(Document); + properties ??= new AndroidFolderProperties(Document); return Task.FromResult(properties); } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs index 3e37cad66..ddb5874d1 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs @@ -43,9 +43,9 @@ protected AndroidStorable(AndroidUri uri, Activity activity, AndroidFolder? pare this.permissionRoot = permissionRoot ?? uri; Inner = uri; - BookmarkId = bookmarkId; - Id = Inner.ToString() ?? string.Empty; + Id = uri.ToString() ?? string.Empty; Name = GetFileName(uri); + BookmarkId = bookmarkId; } /// @@ -89,7 +89,7 @@ public Task RemoveBookmarkAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } - + /// public abstract Task GetPropertiesAsync(); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFileProperties.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFileProperties.cs index 4f145d407..f911eba97 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFileProperties.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFileProperties.cs @@ -6,7 +6,7 @@ namespace SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties { /// - internal sealed class AndroidFileProperties : ISizeProperties, IBasicProperties + internal sealed class AndroidFileProperties : ISizeProperties, IDateProperties, IBasicProperties { private readonly DocumentFile _document; @@ -19,13 +19,32 @@ public AndroidFileProperties(DocumentFile document) public Task?> GetSizeAsync(CancellationToken cancellationToken = default) { var sizeProperty = new GenericProperty(_document.Length()); - return Task.FromResult>(sizeProperty); + return Task.FromResult?>(sizeProperty); + } + + /// + public Task> GetDateCreatedAsync(CancellationToken cancellationToken = default) + { + // Created date is not available on Android + return GetDateModifiedAsync(cancellationToken); + } + + /// + public Task> GetDateModifiedAsync(CancellationToken cancellationToken = default) + { + var timestamp = _document.LastModified(); + var dateModified = DateTimeOffset.FromUnixTimeMilliseconds(timestamp).UtcDateTime; + var dateProperty = new GenericProperty(dateModified); + + return Task.FromResult>(dateProperty); } /// public async IAsyncEnumerable> GetPropertiesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) { yield return await GetSizeAsync(cancellationToken) as IStorageProperty; + yield return await GetDateCreatedAsync(cancellationToken) as IStorageProperty; + yield return await GetDateModifiedAsync(cancellationToken) as IStorageProperty; } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFolderProperties.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFolderProperties.cs index 329c1069f..35165bf87 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFolderProperties.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFolderProperties.cs @@ -6,7 +6,7 @@ namespace SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties { /// - internal sealed class AndroidFolderProperties : ISizeProperties, IBasicProperties + internal sealed class AndroidFolderProperties : IDateProperties, IBasicProperties { private readonly DocumentFile _document; @@ -16,17 +16,27 @@ public AndroidFolderProperties(DocumentFile document) } /// - public Task?> GetSizeAsync(CancellationToken cancellationToken = default) + public Task> GetDateCreatedAsync(CancellationToken cancellationToken = default) { - var sizeProperty = new GenericProperty(_document.Length()); - return Task.FromResult>(sizeProperty); + // Created date is not available on Android + return GetDateModifiedAsync(cancellationToken); + } + + /// + public Task> GetDateModifiedAsync(CancellationToken cancellationToken = default) + { + var timestamp = _document.LastModified(); + var dateModified = DateTimeOffset.FromUnixTimeMilliseconds(timestamp).UtcDateTime; + var dateProperty = new GenericProperty(dateModified); + + return Task.FromResult>(dateProperty); } /// public async IAsyncEnumerable> GetPropertiesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) { - yield return await GetSizeAsync(cancellationToken) as IStorageProperty; + yield return await GetDateCreatedAsync(cancellationToken) as IStorageProperty; + yield return await GetDateModifiedAsync(cancellationToken) as IStorageProperty; } } } - diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Templates/AndroidDataTemplates.xaml b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Templates/AndroidDataTemplates.xaml new file mode 100644 index 000000000..d57ba8d29 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Templates/AndroidDataTemplates.xaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + +