diff --git a/global.json b/global.json index f42869719..667d3dc68 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,8 @@ { "msbuild-sdks": { - "Uno.Sdk": "5.4.10" + "Uno.Sdk": "5.6.20" + }, + "sdk": { + "allowPrerelease": true } } 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..04c03b7d4 160000 --- a/lib/nwebdav +++ b/lib/nwebdav @@ -1 +1 @@ -Subproject commit 17fcebe4d06338f94122400c2c9cfdbb5ea8c77e +Subproject commit 04c03b7d47ec54046919d33d41d6761d4ccc5318 diff --git a/src/Platforms/Directory.Packages.props b/src/Platforms/Directory.Packages.props index 6cbaa6ad1..ccf56d713 100644 --- a/src/Platforms/Directory.Packages.props +++ b/src/Platforms/Directory.Packages.props @@ -2,31 +2,29 @@ - - - + + + - + - - + - - - - - + + + + + - - - - + + + + - - + @@ -35,6 +33,7 @@ - + + - + \ No newline at end of file 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..fc0380789 100644 --- a/src/Platforms/SecureFolderFS.Maui/App.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/App.xaml.cs @@ -1,4 +1,6 @@ -using CommunityToolkit.Mvvm.DependencyInjection; +using APES.UI.XF; +using SecureFolderFS.Maui.Extensions.Mappers; +using SecureFolderFS.Maui.Helpers; using SecureFolderFS.Shared; using SecureFolderFS.UI.Helpers; @@ -6,8 +8,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 +20,7 @@ public partial class App : Application #else null; #endif - + public event EventHandler? AppResumed; public event EventHandler? AppPutToForeground; @@ -26,6 +28,11 @@ public App() { InitializeComponent(); + // Configure mappers + CustomMappers.AddEntryMappers(); + CustomMappers.AddLabelMappers(); + CustomMappers.AddPickerMappers(); + // Configure exception handlers AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; @@ -33,8 +40,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); } @@ -49,10 +56,13 @@ private async Task GetAppShellAsync() // Register IoC DI.Default.SetServiceProvider(ServiceProvider); - + // 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/ImageStream.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs index d80b3989a..a89532d9f 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs @@ -1,25 +1,37 @@ +using SecureFolderFS.Shared.ComponentModel; 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/OnDemandDisposableStream.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/OnDemandDisposableStream.cs new file mode 100644 index 000000000..858bd0397 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/OnDemandDisposableStream.cs @@ -0,0 +1,16 @@ +namespace SecureFolderFS.Maui.AppModels +{ + internal sealed class OnDemandDisposableStream : MemoryStream + { + public void ForceClose() + { + base.Dispose(true); + } + + /// + protected override void Dispose(bool disposing) + { + _ = disposing; + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/VideoStreamServer.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/VideoStreamServer.cs index 794d66bdc..de9d53483 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/VideoStreamServer.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/VideoStreamServer.cs @@ -32,7 +32,7 @@ public Task InitAsync(CancellationToken cancellationToken = default) { _httpListener.Start(); _ = BeginListeningAsync(); - + return Task.CompletedTask; async Task BeginListeningAsync() @@ -41,24 +41,29 @@ async Task BeginListeningAsync() { try { + var response = context.Response; if (context.Request.RawUrl != "/video") { - context.Response.StatusCode = (int)HttpStatusCode.NotFound; - context.Response.Close(); + response.StatusCode = (int)HttpStatusCode.NotFound; + 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); + response.ContentType = _mimeType; + response.ContentLength64 = _videoStream.Length; + response.Headers["Accept-Ranges"] = "bytes"; + + await _videoStream.CopyToAsync(response.OutputStream, 64 * 1024, cancellationToken); + await _videoStream.FlushAsync(cancellationToken); _videoStream.Position = 0L; - break; + + response.StatusCode = (int)HttpStatusCode.OK; + response.StatusDescription = "OK"; + response.Close(); } - catch (Exception) + catch (Exception ex) { + _ = ex; break; } } @@ -72,7 +77,7 @@ public void Dispose() _videoStream.Dispose(); _httpListener.Abort(); } - + private static int GetAvailablePort() { using var listener = new TcpListener(IPAddress.Loopback, 0); diff --git a/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs index 76aa8d21c..61d1f48d7 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs @@ -15,6 +15,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..ff5e0b3db 100644 --- a/src/Platforms/SecureFolderFS.Maui/Extensions/IocExtensions.cs +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/IocExtensions.cs @@ -1,6 +1,9 @@ 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.UI.ServiceImplementation; using SecureFolderFS.UI.ServiceImplementation.Settings; @@ -14,10 +17,12 @@ public static IServiceCollection WithMauiServices(this IServiceCollection servic return serviceCollection .AddSingleton(_ => new(new MauiAppSettings(settingsFolder), new UserSettings(settingsFolder))) .AddSingleton() + .AddSingleton() .AddSingleton() - .AddTransient() .AddSingleton() - //.AddSingleton() + .AddSingleton() + .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..297266790 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs @@ -0,0 +1,38 @@ +using Microsoft.Maui.Handlers; +using Microsoft.Maui.Platform; +using SecureFolderFS.Maui.UserControls.Common; + +#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 + var outerRadii = Enumerable.Range(1, 8).Select(_ => 24f).ToArray(); + var roundRectShape = new RoundRectShape(outerRadii, null, null); + var shape = new ShapeDrawable(roundRectShape); + + shape.Paint!.Color = (App.Instance.Resources["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["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..647ffe472 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs @@ -0,0 +1,38 @@ +using Microsoft.Maui.Handlers; +using Microsoft.Maui.Platform; +using SecureFolderFS.Maui.UserControls.Common; +#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 AddPickerMappers() + { + PickerHandler.Mapper.AppendToMapping($"{nameof(CustomMappers)}.{nameof(Picker)}", (handler, view) => + { + if (view is not ModernPicker modernPicker) + return; + +#if ANDROID + var outerRadii = Enumerable.Range(1, 8).Select(_ => 24f).ToArray(); + 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["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..8d35e86be --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Helpers/MauiThemeHelper.cs @@ -0,0 +1,19 @@ +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() + { + 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..1bbebb36f 100644 --- a/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs +++ b/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs @@ -1,10 +1,7 @@ using APES.UI.XF; using CommunityToolkit.Maui; -using Microsoft.Extensions.Logging; -using The49.Maui.BottomSheet; - +using Plugin.Maui.BottomSheet.Hosting; #if ANDROID -using Material.Components.Maui.Extensions; using MauiIcons.Material; #elif IOS using MauiIcons.Cupertino; @@ -28,16 +25,15 @@ 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 #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 +41,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..5dd70dba8 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml @@ -16,5 +16,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/Helpers/AndroidBiometricHelpers.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidBiometricHelpers.cs new file mode 100644 index 000000000..c10efefef --- /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..2f9d82222 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs @@ -48,7 +48,7 @@ protected override IServiceCollection ConfigureServices(IModifiableFolder settin .AddSingleton() .AddSingleton() .AddSingleton() - + .WithMauiServices(settingsFolder) ; } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs index f55b81860..c2a527e42 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs @@ -1,6 +1,9 @@ using Android.App; using Android.Content; using Android.Content.PM; +using Android.OS; +using AndroidX.Activity; +using Microsoft.Maui.Platform; namespace SecureFolderFS.Maui { @@ -22,6 +25,18 @@ public MainActivity() Instance ??= this; } + /// + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + // Enable edge to edge + EdgeToEdge.Enable(this); +#pragma warning disable CA1422 + Window?.SetStatusBarColor((App.Instance.Resources["PrimaryLightColor"] as Color)!.ToPlatform()); +#pragma warning restore CA1422 + } + /// protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data) { 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..ba44c1ca5 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 + #1791FF + #106bbf #106bbf 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/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/AndroidSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs index 0b71db01b..472891748 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..3318e4ef5 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; @@ -37,6 +38,7 @@ public override async IAsyncEnumerable GetLoginAsync(IF { 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.") }; } @@ -48,6 +50,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..b828edc90 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs @@ -40,27 +40,24 @@ public Task OpenStreamAsync(FileAccess accessMode, CancellationToken can return Task.FromResult(stream); } + if (activity.ContentResolver?.OpenInputStream(Inner) is not InputStreamInvoker inputStream) + return Task.FromException(new UnauthorizedAccessException($"Could not open a stream to: '{Id}'.")); + if (accessMode == FileAccess.Read) { - stream = activity.ContentResolver?.OpenInputStream(Inner); + stream = inputStream; } 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) + if (activity.ContentResolver?.OpenOutputStream(Inner) is not OutputStreamInvoker outputStream) return Task.FromException(new UnauthorizedAccessException($"Could not open a stream to: '{Id}'.")); - var combinedInputStream = new InputOutputStream(inputStream, outputStream, GetFileSize(activity.ContentResolver, Inner)); - stream = combinedInputStream; + stream = new InputOutputStream(inputStream.BaseInputStream, outputStream.BaseOutputStream, GetFileSize(activity.ContentResolver, Inner)); } - - if (stream is null) - return Task.FromException(new UnauthorizedAccessException($"Could not open a stream to: '{Id}'.")); return Task.FromResult(stream); } - + /// public override Task GetPropertiesAsync() { @@ -105,12 +102,12 @@ private static bool IsVirtualFile(Context context, AndroidUri uri) 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 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/ViewModels/AndroidBiometricCreationViewModel.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricCreationViewModel.cs new file mode 100644 index 000000000..41af33367 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricCreationViewModel.cs @@ -0,0 +1,42 @@ +using OwlCore.Storage; +using SecureFolderFS.Core; +using SecureFolderFS.Core.VaultAccess; +using SecureFolderFS.Sdk.EventArguments; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.UI.Helpers; + +namespace SecureFolderFS.Maui.Platforms.Android.ViewModels +{ + internal sealed class AndroidBiometricCreationViewModel(IFolder vaultFolder, string vaultId) : AndroidBiometricViewModel(vaultFolder, vaultId) + { + /// + public override event EventHandler? StateChanged; + + /// + public override event EventHandler? CredentialsProvided; + + /// + protected override async Task ProvideCredentialsAsync(CancellationToken cancellationToken) + { + var vaultWriter = new VaultWriter(VaultFolder, StreamSerializer.Instance); + using var challenge = VaultHelpers.GenerateChallenge(VaultId); + + // Write authentication data to the vault + await vaultWriter.WriteAuthenticationAsync($"{Id}{Constants.Vault.Names.CONFIGURATION_EXTENSION}", new() + { + Capability = "supportsChallenge", // TODO: Put somewhere in Constants + Challenge = challenge.Key + }, cancellationToken); + + try + { + var key = await CreateAsync(VaultId, challenge.Key, cancellationToken); + CredentialsProvided?.Invoke(this, new(key)); + } + catch (Exception ex) + { + Report(Result.Failure(ex)); + } + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricLoginViewModel.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricLoginViewModel.cs new file mode 100644 index 000000000..23e90240a --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricLoginViewModel.cs @@ -0,0 +1,45 @@ +using OwlCore.Storage; +using SecureFolderFS.Core.VaultAccess; +using SecureFolderFS.Sdk.EventArguments; +using SecureFolderFS.Shared.Models; + +namespace SecureFolderFS.Maui.Platforms.Android.ViewModels +{ + internal sealed class AndroidBiometricLoginViewModel(IFolder vaultFolder, string vaultId) : AndroidBiometricViewModel(vaultFolder, vaultId) + { + /// + public override event EventHandler? StateChanged; + + /// + public override event EventHandler? CredentialsProvided; + + /// + protected override async Task ProvideCredentialsAsync(CancellationToken cancellationToken) + { + var vaultReader = new VaultReader(VaultFolder, StreamSerializer.Instance); + var auth = await vaultReader.ReadAuthenticationAsync($"{Id}{Core.Constants.Vault.Names.CONFIGURATION_EXTENSION}", cancellationToken); + + if (auth?.Challenge is null) + { + Report(Result.Failure(new ArgumentNullException(nameof(auth)))); + return; + } + + try + { + // Ask for credentials + var key = await SignAsync(VaultId, auth.Challenge, cancellationToken); + + // Report that credentials were provided and new provision needs to be applied + CredentialsProvided?.Invoke(this, new CredentialsProvidedEventArgs(key)); + + // TODO: Provision is currently disabled since it opens the Android Biometrics dialog for the second time + //StateChanged?.Invoke(this, new CredentialsProvisionChangedEventArgs(newChallenge.CreateCopy(), newSignedChallenge.CreateCopy())); + } + catch (Exception ex) + { + Report(Result.Failure(ex)); + } + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricViewModel.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricViewModel.cs new file mode 100644 index 000000000..e9953ad84 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricViewModel.cs @@ -0,0 +1,154 @@ +using System.Security.Cryptography; +using Android.Hardware.Biometrics; +using AndroidX.Core.Content; +using Java.Security; +using OwlCore.Storage; +using SecureFolderFS.Core; +using SecureFolderFS.Core.Cryptography.SecureStore; +using SecureFolderFS.Maui.AppModels; +using SecureFolderFS.Maui.Platforms.Android.Helpers; +using SecureFolderFS.Sdk.Enums; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.ViewModels.Controls.Authentication; +using IKey = SecureFolderFS.Shared.ComponentModel.IKey; + +namespace SecureFolderFS.Maui.Platforms.Android.ViewModels +{ + public abstract class AndroidBiometricViewModel : AuthenticationViewModel + { + private const string KEY_ALIAS_PREFIX = "securefolderfs_biometric_"; + private const string KEYSTORE_PROVIDER = "AndroidKeyStore"; + + /// + /// Gets the unique ID of the vault. + /// + protected string VaultId { get; } + + /// + /// Gets the associated folder of the vault. + /// + protected IFolder VaultFolder { get; } + + /// + public sealed override AuthenticationStage Availability { get; } = AuthenticationStage.Any; + + public AndroidBiometricViewModel(IFolder vaultFolder, string vaultId) + : base(Constants.Vault.Authentication.AUTH_ANDROID_BIOMETRIC) + { + Title = "AndroidBiometrics".ToLocalized(); + VaultFolder = vaultFolder; + VaultId = vaultId; + } + + /// + public override Task RevokeAsync(string? id, CancellationToken cancellationToken = default) + { + var keyStore = KeyStore.GetInstance(KEYSTORE_PROVIDER); + if (keyStore is null) + return Task.CompletedTask; + + keyStore.Load(null); + var alias = $"{KEY_ALIAS_PREFIX}{id}"; + if (keyStore.ContainsAlias(alias)) + keyStore.DeleteEntry(alias); + + return Task.CompletedTask; + } + + /// + public override async Task CreateAsync(string id, byte[]? data, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(data); + var keyStore = KeyStore.GetInstance(KEYSTORE_PROVIDER); + if (keyStore is null) + throw new CryptographicException("Android KeyStore could not be loaded."); + + // Load the KeyStore with default parameters + keyStore.Load(null); + + IPrivateKey privateKey; + var alias = $"{KEY_ALIAS_PREFIX}{id}"; + if (!keyStore.ContainsAlias(alias)) + { + var keyPairGenerator = AndroidBiometricHelpers.GetKeyPairGenerator(alias, KEYSTORE_PROVIDER); + var keyPair = keyPairGenerator?.GenerateKeyPair(); + privateKey = keyPair?.Private ?? throw new CryptographicException("KeyPair could not be generated."); + } + else + { + var privateKeyEntry = keyStore.GetEntry(alias, null) as KeyStore.PrivateKeyEntry; + privateKey = privateKeyEntry?.PrivateKey ?? throw new CryptographicException("Private key could not be found."); + } + + return await MakeSignatureAsync(privateKey, data); + } + + /// + public override async Task SignAsync(string id, byte[]? data, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(data); + var keyStore = KeyStore.GetInstance(KEYSTORE_PROVIDER); + if (keyStore is null) + throw new CryptographicException("Android KeyStore could not be loaded."); + + // Load the KeyStore with default parameters + keyStore.Load(null); + + var alias = $"{KEY_ALIAS_PREFIX}{id}"; + var privateKeyEntry = keyStore.GetEntry(alias, null) as KeyStore.PrivateKeyEntry; + var privateKey = privateKeyEntry?.PrivateKey ?? throw new CryptographicException("Private key could not be found."); + + return await MakeSignatureAsync(privateKey, data); + } + + private static async Task MakeSignatureAsync(IPrivateKey privateKey, byte[] data) + { + var signature = Signature.GetInstance("SHA256withRSA"); + if (signature is null) + throw new CryptographicException("Signature could not be loaded."); + + // Init signature + signature.InitSign(privateKey); + + var tcs = new TaskCompletionSource(); + var executor = ContextCompat.GetMainExecutor(MainActivity.Instance!); + var promptInfo = new BiometricPrompt.Builder(MainActivity.Instance!) + .SetTitle("Authenticate".ToLocalized()) + .SetSubtitle("Authenticate to create and sign challenge") + .SetNegativeButton("Cancel", executor, new DialogOnClickListener((_, _) => + { + tcs.TrySetCanceled(); + })) + .Build(); + + promptInfo.Authenticate(new BiometricPrompt.CryptoObject(signature), new(), executor, + new BiometricPromptCallback( + onSuccess: result => + { + try + { + if (result?.CryptoObject?.Signature is null) + return; + + var signedBytes = AndroidBiometricHelpers.SignData(result.CryptoObject.Signature, data); + if (signedBytes is null) + throw new CryptographicException("Could not sign the data."); + + tcs.TrySetResult(SecureKey.TakeOwnership(signedBytes)); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }, + onError: (code, message) => + { + _ = message; + //tcs.TrySetException(new Exception($"Biometric error {code}: {message}")); + }, + onFailure: null)); + + return await tcs.Task; + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/BaseContentPageHandler.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/BaseContentPageHandler.cs index c04e45dbb..b29929385 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/BaseContentPageHandler.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/BaseContentPageHandler.cs @@ -14,7 +14,7 @@ protected override void ConnectHandler(ContentView platformView) base.ConnectHandler(platformView); if (ThisPage is null) return; - + ThisPage.Loaded += ContentPage_Loaded; ThisPage.Appearing += ContentPage_Appearing; App.Instance.AppResumed += App_Resumed; @@ -43,7 +43,7 @@ private void ApplyHandler() { if (this is not IPlatformViewHandler viewHandler) return; - + ApplyHandler(viewHandler); } @@ -51,7 +51,7 @@ private async void ContentPage_Loaded(object? sender, EventArgs e) { ThisPage!.NavigatedTo += ContentPage_NavigatedTo; ThisPage!.Unloaded += ContentPage_Unloaded; - + // Await a small delay for the UI to load await Task.Delay(10); ApplyHandler(); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/ContentPageExHandler.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/ContentPageExHandler.cs index d1ed3acea..fced53224 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/ContentPageExHandler.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/ContentPageExHandler.cs @@ -21,7 +21,7 @@ protected override void ApplyHandler(IPlatformViewHandler viewHandler) notifyCollectionChanged.CollectionChanged -= ToolbarItemsEx_CollectionChanged; notifyCollectionChanged.CollectionChanged += ToolbarItemsEx_CollectionChanged; } - + if (viewHandler.ViewController?.ParentViewController?.NavigationItem is { } navItem) UpdateToolbarItems(thisPageEx, navItem); } @@ -30,7 +30,7 @@ private void ToolbarItemsEx_CollectionChanged(object? sender, NotifyCollectionCh { if (ThisPage is not ContentPageExtended thisPageEx) return; - + if (ViewController?.ParentViewController?.NavigationItem is { } navItem) UpdateToolbarItems(thisPageEx, navItem); } @@ -43,20 +43,20 @@ private void UpdateToolbarItems(ContentPageExtended contentPage, UINavigationIte navigationItem.RightBarButtonItems = null; return; } - + // Don't do anything if there are some items in both platform view and ToolbarItems if (!contentPage.ExToolbarItems.IsEmpty() && !navigationItem.RightBarButtonItems.IsEmpty()) return; var rightBarItems = new List(); - + // Get primary items foreach (var item in contentPage.ExToolbarItems) { if (item.Order != ToolbarItemOrder.Secondary) rightBarItems.Add(item.ToUIBarButtonItem()); } - + // Get secondary items var secondaryItems = contentPage.ExToolbarItems .Where(x => x.Order == ToolbarItemOrder.Secondary) @@ -67,17 +67,17 @@ private void UpdateToolbarItems(ContentPageExtended contentPage, UINavigationIte { // Create a popup UIMenu var menu = UIMenu.Create(string.Empty, null, UIMenuIdentifier.Edit, UIMenuOptions.DisplayInline, secondaryItems); - + // Set ellipsis icon image (can also use UIImage.ActionsImage for a filled ellipsis) var menuButton = new UIBarButtonItem(UIImage.FromBundle("cupertino_ellipsis.png"), menu); // Add to final bar items rightBarItems.Add(menuButton); } - + // Assign the navigation bar buttons navigationItem.RightBarButtonItems = rightBarItems.ToArray(); - + static UIMenuElement CreateUIMenuElement(ExMenuItemBase item) { // Create a UIAction for each ToolbarItem diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/ContentPageHandler.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/ContentPageHandler.cs index a8a16cda6..45c8143f7 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/ContentPageHandler.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Handlers/ContentPageHandler.cs @@ -14,7 +14,7 @@ protected override void ApplyHandler(IPlatformViewHandler viewHandler) { if (ThisPage is null) return; - + if (viewHandler.ViewController?.NavigationController?.NavigationBar is { } navigationBar) UpdateTitleMode(ThisPage, navigationBar); @@ -34,14 +34,14 @@ private void UpdateToolbarItems(ContentPage contentPage, UINavigationItem naviga return; var rightBarItems = new List(); - + // Get primary items foreach (var item in contentPage.ToolbarItems) { if (item.Order != ToolbarItemOrder.Secondary) rightBarItems.Add(item.ToUIBarButtonItem()); } - + // Get secondary items var secondaryItems = contentPage.ToolbarItems .Where(x => x.Order == ToolbarItemOrder.Secondary) @@ -52,17 +52,17 @@ private void UpdateToolbarItems(ContentPage contentPage, UINavigationItem naviga { // Create a popup UIMenu var menu = UIMenu.Create(string.Empty, null, UIMenuIdentifier.Edit, UIMenuOptions.DisplayInline, secondaryItems); - + // Set ellipsis icon image (can also use UIImage.ActionsImage for a filled ellipsis) var menuButton = new UIBarButtonItem(UIImage.FromBundle("cupertino_ellipsis.png"), menu); // Add to final bar items rightBarItems.Add(menuButton); } - + // Assign the navigation bar buttons navigationItem.RightBarButtonItems = rightBarItems.ToArray(); - + static UIMenuElement CreateUIMenuElement(ToolbarItem item) { // Create a UIAction for each ToolbarItem diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSFileExplorerService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSFileExplorerService.cs index 5e34d7804..0d5383526 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSFileExplorerService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSFileExplorerService.cs @@ -1,13 +1,14 @@ using System.Diagnostics.CodeAnalysis; using CommunityToolkit.Maui.Storage; using Foundation; -using Intents; using OwlCore.Storage; using SecureFolderFS.Maui.Platforms.iOS.Storage; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Storage.Pickers; using UIKit; using UniformTypeIdentifiers; +using UTType = MobileCoreServices.UTType; namespace SecureFolderFS.Maui.Platforms.iOS.ServiceImplementation { @@ -20,11 +21,11 @@ public Task TryOpenInFileExplorerAsync(IFolder folder, CancellationToken cancell { if (folder is not IWrapper wrapper) return Task.CompletedTask; - + // Open the folder in the Files app var documentPicker = new UIDocumentPickerViewController(wrapper.Inner, UIDocumentPickerMode.Open); UIApplication.SharedApplication.KeyWindow?.RootViewController?.PresentViewController(documentPicker, true, null); - + return Task.CompletedTask; } @@ -38,12 +39,12 @@ public async Task SaveFileAsync(string suggestedName, Stream dataStream, I } /// - public async Task PickFileAsync(IEnumerable? filter, CancellationToken cancellationToken = default) + public async Task PickFileAsync(PickerOptions? options, bool offerPersistence = true, CancellationToken cancellationToken = default) { AssertCanPick(); using var documentPicker = new UIDocumentPickerViewController([ - MobileCoreServices.UTType.Content, - MobileCoreServices.UTType.Item, + UTType.Content, + UTType.Item, "public.data"], UIDocumentPickerMode.Open); var nsUrl = await PickInternalAsync(documentPicker, cancellationToken); @@ -57,7 +58,7 @@ public async Task SaveFileAsync(string suggestedName, Stream dataStream, I } /// - public async Task PickFolderAsync(CancellationToken cancellationToken = default) + public async Task PickFolderAsync(PickerOptions? options, bool offerPersistence = true, CancellationToken cancellationToken = default) { AssertCanPick(); using var documentPicker = new UIDocumentPickerViewController([UTTypes.Folder], false); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs index aa18d08d3..ea1b78638 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs @@ -1,3 +1,5 @@ +using Foundation; +using OwlCore.Storage; using SecureFolderFS.Sdk.Services; namespace SecureFolderFS.Maui.Platforms.iOS.ServiceImplementation @@ -6,6 +8,27 @@ namespace SecureFolderFS.Maui.Platforms.iOS.ServiceImplementation internal sealed class IOSSystemService : ISystemService { /// - public event EventHandler? DesktopLocked; + public event EventHandler? DeviceLocked; + + /// + public async Task GetAvailableFreeSpaceAsync(IFolder storageRoot, CancellationToken cancellationToken = default) + { +#if IOS + await Task.CompletedTask; + var path = storageRoot.Id; + if (string.IsNullOrEmpty(path)) + throw new ArgumentException("Invalid storage root path.", nameof(storageRoot)); + + var fileManager = NSFileManager.DefaultManager; + var attributes = fileManager.GetFileSystemAttributes(path, out var error); + if (attributes is null || error is not null) + throw new IOException($"Unable to get file system attributes for path: {path}. Error: {error.LocalizedDescription}."); + + return (long)attributes.FreeSize; +#else + await Task.CompletedTask; + throw new PlatformNotSupportedException("Only implemented on iOS."); +#endif + } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs index cd0347886..db9a2bf25 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs @@ -1,11 +1,12 @@ using System.Runtime.CompilerServices; using OwlCore.Storage; +using SecureFolderFS.Core; using SecureFolderFS.Core.VaultAccess; -using SecureFolderFS.Sdk.AppModels; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.ViewModels.Controls.Authentication; +using SecureFolderFS.Shared.Models; using SecureFolderFS.UI.ServiceImplementation; -using SecureFolderFS.UI.ViewModels; +using SecureFolderFS.UI.ViewModels.Authentication; namespace SecureFolderFS.Maui.Platforms.iOS.ServiceImplementation { @@ -17,14 +18,14 @@ 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 authenticationMethods = config.AuthenticationMethod.Split(Constants.Vault.Authentication.SEPARATOR); foreach (var item in authenticationMethods) { yield return item switch { - Core.Constants.Vault.Authentication.AUTH_PASSWORD => new PasswordLoginViewModel(), - Core.Constants.Vault.Authentication.AUTH_KEYFILE => new KeyFileLoginViewModel(vaultFolder), + Constants.Vault.Authentication.AUTH_PASSWORD => new PasswordLoginViewModel(), + Constants.Vault.Authentication.AUTH_KEYFILE => new KeyFileLoginViewModel(vaultFolder), _ => throw new NotSupportedException($"The authentication method '{item}' is not supported by the platform.") }; } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs index 72e49b40d..2e4382ec2 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs @@ -16,7 +16,7 @@ public Task OpenStreamAsync(FileAccess accessMode, CancellationToken can { var iosStream = accessMode switch { - FileAccess.ReadWrite or FileAccess.Write => new IOSSecurityScopedStream(Inner, permissionRoot, FileAccess.Write), + FileAccess.ReadWrite or FileAccess.Write => new IOSSecurityScopedStream(Inner, permissionRoot, FileAccess.ReadWrite), _ => new IOSSecurityScopedStream(Inner, permissionRoot, FileAccess.Read) }; diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml index 90fd3bc2f..8632ddb72 100644 --- a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml @@ -3,16 +3,17 @@ xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:l="using:SecureFolderFS.Maui.Localization" - xmlns:mdc="clr-namespace:Material.Components.Maui;assembly=Material.Components.Maui" xmlns:ts="clr-namespace:SecureFolderFS.Maui.TemplateSelectors" xmlns:tv="clr-namespace:CommunityToolkit.Maui.Views;assembly=CommunityToolkit.Maui" xmlns:uc="clr-namespace:SecureFolderFS.Maui.UserControls" + xmlns:uco="clr-namespace:SecureFolderFS.Maui.UserControls.Options" xmlns:vc="clr-namespace:SecureFolderFS.Maui.ValueConverters" xmlns:vm="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls;assembly=SecureFolderFS.Sdk" xmlns:vm2="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls.Authentication;assembly=SecureFolderFS.Sdk" xmlns:vm3="clr-namespace:SecureFolderFS.Sdk.ViewModels.Views.Credentials;assembly=SecureFolderFS.Sdk" x:Name="ThisPopup" - BindingContext="{x:Reference ThisPopup}"> + BindingContext="{x:Reference ThisPopup}" + Color="Transparent"> @@ -30,53 +31,38 @@ - - -