From 85e72b0ee01e180f37fc8b0f5ae08ab87d07d372 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:37:12 +0100 Subject: [PATCH 01/79] Minor improvements around widgets insertion --- .../Validators/StructureContentsValidator.cs | 1 + .../UserControls/IntroductionSlide.xaml | 2 +- .../SecureFolderFS.Maui/Views/IntroductionPage.xaml | 5 +++-- .../UserControls/Widgets/AggregatedDataWidget.xaml | 2 +- .../Controls/Widgets/WidgetsListViewModel.cs | 11 ++++++++++- .../ViewModels/Views/Vault/VaultHealthViewModel.cs | 3 +++ 6 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs index 63fa5c6eb..d619238eb 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs @@ -38,6 +38,7 @@ public override async Task ValidateResultAsync((IFolder, IProgress - + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs index 66ce9c1be..5157ec3f2 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs @@ -25,7 +25,7 @@ namespace SecureFolderFS.Uno.UserControls.InterfaceHost { - public sealed partial class MainAppHostControl : UserControl, IRecipient, IRecipient + public sealed partial class MainAppHostControl : UserControl, IRecipient #if WINDOWS , IRecipient #endif @@ -47,18 +47,6 @@ public void Receive(VaultRemovedMessage message) Navigation?.ClearContent(); } - /// - public void Receive(VaultAddedMessage message) - { -#if WINDOWS - if (ViewModel?.VaultListViewModel.Items.Count >= SecureFolderFS.Sdk.Constants.Vault.MAX_FREE_AMOUNT_OF_VAULTS - && !SettingsService.AppSettings.WasBetaNotificationShown1) - { - BetaTeachingTip.IsOpen = true; - } -#endif - } - #if WINDOWS /// public void Receive(VaultSelectionRequestedMessage message) @@ -160,7 +148,6 @@ private void RenameBox_LostFocus(object sender, RoutedEventArgs e) private async void MainAppHostControl_Loaded(object sender, RoutedEventArgs e) { WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); #if WINDOWS WeakReferenceMessenger.Default.Register(this); #endif @@ -190,12 +177,6 @@ private async void SidebarSearchBox_TextChanged(AutoSuggestBox sender, AutoSugge await ViewModel!.VaultListViewModel.SearchViewModel.SubmitQueryAsync(sender.Text); } - private async void TeachingTip_CloseButtonClick(TeachingTip sender, object args) - { - SettingsService.AppSettings.WasBetaNotificationShown1 = true; - await SettingsService.AppSettings.TrySaveAsync(); - } - #region Drag and Drop private void Sidebar_DragOver(object sender, DragEventArgs e) diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IAppSettings.cs b/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IAppSettings.cs index 5a343b7c5..3a64ba6af 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IAppSettings.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IAppSettings.cs @@ -9,11 +9,6 @@ namespace SecureFolderFS.Sdk.Services.Settings /// public interface IAppSettings : IPersistable, INotifyPropertyChanged { - /// - /// Gets or sets the value that determines whether the (first) notification about the beta program was shown. - /// - bool WasBetaNotificationShown1 { get; set; } - /// /// Gets or sets the value that determines whether the explanation of vault folder was shown. /// From b07c862ee967972a85e849064ae8378326b8801c Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:47:56 +0200 Subject: [PATCH 33/79] Begin working on logging --- lib/nwebdav | 2 +- .../Helpers/BaseLifecycleHelper.cs | 21 ++++++- .../Storage/Browser/FolderViewModel.cs | 6 +- src/Shared/SecureFolderFS.Shared/DI.cs | 23 +++++++ .../Extensions/LoggingExtensions.cs | 24 ++++++++ .../Logging/DebugOutputLogger.cs | 60 +++++++++++++++++++ .../Logging/DebugOutputLoggerProvider.cs | 32 ++++++++++ .../Logging/FileOutputLogger.cs | 60 +++++++++++++++++++ .../Logging/FileOutputLoggerProvider.cs | 48 +++++++++++++++ .../Logging/LoggingBuilderExtensions.cs | 34 +++++++++++ .../SecureFolderFS.Shared.csproj | 3 +- .../Helpers/SourceGeneratorHelpers.cs | 29 +++++++++ .../InjectGenerator.cs | 56 ++++++++++++----- .../SecureFolderFS.Tests.csproj | 2 +- 14 files changed, 380 insertions(+), 20 deletions(-) create mode 100644 src/Shared/SecureFolderFS.Shared/Extensions/LoggingExtensions.cs create mode 100644 src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLogger.cs create mode 100644 src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLoggerProvider.cs create mode 100644 src/Shared/SecureFolderFS.Shared/Logging/FileOutputLogger.cs create mode 100644 src/Shared/SecureFolderFS.Shared/Logging/FileOutputLoggerProvider.cs create mode 100644 src/Shared/SecureFolderFS.Shared/Logging/LoggingBuilderExtensions.cs diff --git a/lib/nwebdav b/lib/nwebdav index 1e3693ce3..e1df3f4a1 160000 --- a/lib/nwebdav +++ b/lib/nwebdav @@ -1 +1 @@ -Subproject commit 1e3693ce3789409464873480bce920d5711307a7 +Subproject commit e1df3f4a1c80aef525814c0c193ed56251224be2 diff --git a/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs b/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs index 6a26147c9..54647b2cf 100644 --- a/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs +++ b/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs @@ -4,11 +4,13 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using OwlCore.Storage; using OwlCore.Storage.System.IO; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Logging; using SecureFolderFS.UI.ServiceImplementation; using AddService = Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions; @@ -56,7 +58,7 @@ public virtual void LogException(Exception? ex) protected virtual IServiceCollection ConfigureServices(IModifiableFolder settingsFolder) { - return ServiceCollection + ServiceCollection // Singleton services .Foundation(AddService.AddSingleton) @@ -72,7 +74,22 @@ protected virtual IServiceCollection ConfigureServices(IModifiableFolder setting #else .Foundation(AddService.AddSingleton) #endif - ; // Finish service initialization + ; + + // Configure logging + ServiceCollection.AddLogging(builder => + { +#if DEBUG + builder.SetMinimumLevel(LogLevel.Trace); + builder.AddDebugOutput(LogLevel.Trace); +#else + builder.SetMinimumLevel(LogLevel.Warning); +#endif + // Opt-in: file logging + // builder.AddFileOutput(Path.Combine(AppDirectory, "app.log"), LogLevel.Information); + }); + + return ServiceCollection; } public abstract void LogExceptionToFile(Exception? ex); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs index a31ba3f9f..681b43ca9 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Services; @@ -16,7 +17,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser { - [Inject] + [Inject, Inject] [Bindable(true)] public partial class FolderViewModel : BrowserItemViewModel, IViewDesignation { @@ -76,6 +77,7 @@ public virtual void OnDisappearing() /// A that represents the asynchronous operation. public async Task ListContentsAsync(CancellationToken cancellationToken = default) { + var scope = Logger.GetPerformanceScope(); SelectedItems.Clear(); Items.DisposeAll(); Items.Clear(); @@ -92,6 +94,8 @@ public async Task ListContentsAsync(CancellationToken cancellationToken = defaul // Apply adaptive layout if (SettingsService.UserSettings.IsAdaptiveLayoutEnabled && BrowserViewModel.TransferViewModel is { IsPickingFolder: false }) ApplyAdaptiveLayout(); + + Logger.LogPerformance(scope, minThresholdMs: 200); } /// diff --git a/src/Shared/SecureFolderFS.Shared/DI.cs b/src/Shared/SecureFolderFS.Shared/DI.cs index 225c210ac..ce00eaf07 100644 --- a/src/Shared/SecureFolderFS.Shared/DI.cs +++ b/src/Shared/SecureFolderFS.Shared/DI.cs @@ -1,4 +1,5 @@ using System; +using Microsoft.Extensions.Logging; namespace SecureFolderFS.Shared { @@ -19,6 +20,17 @@ public sealed class DI : IServiceProvider /// public bool IsAvailable => _serviceProvider is not null; + /// + /// Retrieves a logger instance for the specified type. + /// + /// The type for which the logger is created. + /// An instance for the specified type. + public ILogger GetLogger() + where T : class + { + return GetService().CreateLogger(); + } + /// public object? GetService(Type serviceType) { @@ -53,6 +65,17 @@ public T GetService() return GetService(); } + /// + /// Retrieves a logger instance for the specified type using the default service provider. + /// + /// The type for which the logger is created. + /// An instance for the specified type. + public static ILogger Logger() + where T : class + { + return Default.GetLogger(); + } + /// /// Resolves a specific service identified by from . /// diff --git a/src/Shared/SecureFolderFS.Shared/Extensions/LoggingExtensions.cs b/src/Shared/SecureFolderFS.Shared/Extensions/LoggingExtensions.cs new file mode 100644 index 000000000..bbebbf8c1 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Extensions/LoggingExtensions.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; + +namespace SecureFolderFS.Shared.Extensions +{ + public static class LoggingExtensions + { + public static long GetPerformanceScope(this ILogger logger) + { + return Stopwatch.GetTimestamp(); + } + + public static void LogPerformance(this ILogger logger, long scope, int minThresholdMs = -1, [CallerMemberName] string caller = "") + { + var elapsed = Stopwatch.GetElapsedTime(scope); + if (!logger.IsEnabled(LogLevel.Debug)) + return; + + if (minThresholdMs < 0 || elapsed.TotalMilliseconds > minThresholdMs) + logger.LogDebug("{Caller} completed in {ElapsedMs:F2}ms", caller, elapsed.TotalMilliseconds); + } + } +} diff --git a/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLogger.cs b/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLogger.cs new file mode 100644 index 000000000..ed923e28f --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLogger.cs @@ -0,0 +1,60 @@ +using System; +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace SecureFolderFS.Shared.Logging +{ + internal sealed class DebugOutputLogger : ILogger + { + private readonly string _categoryName; + private readonly LogLevel _minLevel; + + public DebugOutputLogger(string categoryName, LogLevel minLevel) + { + _categoryName = categoryName; + _minLevel = minLevel; + } + + /// + public bool IsEnabled(LogLevel logLevel) + { +#if !DEBUG + return false; +#endif + return logLevel >= _minLevel && logLevel != LogLevel.None; + } + + /// + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + return; + + var message = formatter(state, exception); + var timestamp = DateTime.Now.ToString("HH:mm:ss.fff"); + var level = GetLevelTag(logLevel); + + Debug.WriteLine($"[{timestamp}] {level} {_categoryName}: {message}"); + + if (exception is not null) + Debug.WriteLine($"[{timestamp}] {level} {_categoryName}: >>> {exception}"); + } + + private static string GetLevelTag(LogLevel logLevel) => logLevel switch + { + LogLevel.Trace => "[TRC]", + LogLevel.Debug => "[DBG]", + LogLevel.Information => "[INF]", + LogLevel.Warning => "[WRN]", + LogLevel.Error => "[ERR]", + LogLevel.Critical => "[CRT]", + _ => "[???]" + }; + } +} diff --git a/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLoggerProvider.cs b/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLoggerProvider.cs new file mode 100644 index 000000000..1be588213 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLoggerProvider.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace SecureFolderFS.Shared.Logging +{ + /// + /// An that writes log messages to the IDE Debug Output window via . + /// + public sealed class DebugOutputLoggerProvider : ILoggerProvider + { + private readonly ConcurrentDictionary _loggers = new(); + private readonly LogLevel _minLevel; + + public DebugOutputLoggerProvider(LogLevel minLevel = LogLevel.Trace) + { + _minLevel = minLevel; + } + + /// + public ILogger CreateLogger(string categoryName) + { + return _loggers.GetOrAdd(categoryName, name => new DebugOutputLogger(name, _minLevel)); + } + + /// + public void Dispose() + { + _loggers.Clear(); + } + } +} diff --git a/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLogger.cs b/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLogger.cs new file mode 100644 index 000000000..fd1c77f25 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLogger.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using Microsoft.Extensions.Logging; + +namespace SecureFolderFS.Shared.Logging +{ + internal sealed class FileOutputLogger : ILogger + { + private readonly string _categoryName; + private readonly LogLevel _minLevel; + private readonly FileOutputLoggerProvider _provider; + + public FileOutputLogger(string categoryName, LogLevel minLevel, FileOutputLoggerProvider provider) + { + _categoryName = categoryName; + _minLevel = minLevel; + _provider = provider; + } + + /// + public bool IsEnabled(LogLevel logLevel) + { + return logLevel >= _minLevel && logLevel != LogLevel.None; + } + + /// + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + return; + + var message = formatter(state, exception); + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); + var level = GetLevelTag(logLevel); + + var line = $"[{timestamp}] {level} {_categoryName}: {message}"; + if (exception is not null) + line += $"{Environment.NewLine} >>> {exception}"; + + _provider.WriteMessage(line); + } + + private static string GetLevelTag(LogLevel logLevel) => logLevel switch + { + LogLevel.Trace => "[TRC]", + LogLevel.Debug => "[DBG]", + LogLevel.Information => "[INF]", + LogLevel.Warning => "[WRN]", + LogLevel.Error => "[ERR]", + LogLevel.Critical => "[CRT]", + _ => "[???]" + }; + } +} diff --git a/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLoggerProvider.cs b/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLoggerProvider.cs new file mode 100644 index 000000000..93ea1e653 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLoggerProvider.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using Microsoft.Extensions.Logging; + +namespace SecureFolderFS.Shared.Logging +{ + /// + /// An that writes log messages to a file on disk. + /// + public sealed class FileOutputLoggerProvider : ILoggerProvider + { + private readonly ConcurrentDictionary _loggers = new(); + private readonly object _writeLock = new(); + private readonly string _filePath; + private readonly LogLevel _minLevel; + + public FileOutputLoggerProvider(string filePath, LogLevel minLevel = LogLevel.Information) + { + _filePath = filePath; + _minLevel = minLevel; + + var directory = Path.GetDirectoryName(filePath); + if (directory is not null) + Directory.CreateDirectory(directory); + } + + /// + public ILogger CreateLogger(string categoryName) + { + return _loggers.GetOrAdd(categoryName, name => new FileOutputLogger(name, _minLevel, this)); + } + + internal void WriteMessage(string message) + { + lock (_writeLock) + { + File.AppendAllText(_filePath, message + Environment.NewLine); + } + } + + /// + public void Dispose() + { + _loggers.Clear(); + } + } +} diff --git a/src/Shared/SecureFolderFS.Shared/Logging/LoggingBuilderExtensions.cs b/src/Shared/SecureFolderFS.Shared/Logging/LoggingBuilderExtensions.cs new file mode 100644 index 000000000..2b63a0b83 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Logging/LoggingBuilderExtensions.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace SecureFolderFS.Shared.Logging +{ + public static class LoggingBuilderExtensions + { + /// + /// Adds that writes to the IDE Debug Output window. + /// + /// The to configure. + /// The minimum log level to output. + /// The so that calls can be chained. + public static ILoggingBuilder AddDebugOutput(this ILoggingBuilder builder, LogLevel minLevel = LogLevel.Trace) + { + builder.Services.AddSingleton(new DebugOutputLoggerProvider(minLevel)); + return builder; + } + + /// + /// Adds that writes to a log file on disk. + /// + /// The to configure. + /// The absolute path to the log file. + /// The minimum log level to output. + /// The so that calls can be chained. + public static ILoggingBuilder AddFileOutput(this ILoggingBuilder builder, string filePath, LogLevel minLevel = LogLevel.Information) + { + builder.Services.AddSingleton(new FileOutputLoggerProvider(filePath, minLevel)); + return builder; + } + } +} diff --git a/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj b/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj index eb3625cbf..202891eac 100644 --- a/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj +++ b/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj @@ -8,7 +8,8 @@ - + + diff --git a/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs b/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs index 62508b35e..52d1f650c 100644 --- a/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs +++ b/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs @@ -12,6 +12,35 @@ namespace SecureFolderFS.SourceGenerator.Helpers { internal static class SourceGeneratorHelpers { + /// + /// Generate the following code: + /// + /// global::Microsoft.Extensions.Logging.LoggerFactoryExtensions.CreateLogger<>( + /// global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<global::Microsoft.Extensions.Logging.ILoggerFactory>()); + /// + /// + /// + internal static ExpressionSyntax GetLoggerRegistration(string containingTypeName, string serviceProviderName) + { + // ServiceProviderServiceExtensions.GetRequiredService(this.ServiceProvider) + var getLoggerFactory = InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions"), + GenericName("GetRequiredService").WithTypeArgumentList( + TypeArgumentList(SeparatedList().Add( + ParseTypeName("global::Microsoft.Extensions.Logging.ILoggerFactory")))))) + .AddArgumentListArguments(Argument(GetThisMemberAccessExpression(serviceProviderName))); + + // LoggerFactoryExtensions.CreateLogger(loggerFactory) + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("global::Microsoft.Extensions.Logging.LoggerFactoryExtensions"), + GenericName("CreateLogger").WithTypeArgumentList( + TypeArgumentList(SeparatedList().Add( + ParseTypeName(containingTypeName)))))) + .AddArgumentListArguments(Argument(getLoggerFactory)); + } + /// /// Generate the following code: /// diff --git a/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs b/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs index 6484a5b2d..f09195690 100644 --- a/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs +++ b/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs @@ -15,6 +15,9 @@ namespace SecureFolderFS.SourceGenerator [Generator(LanguageNames.CSharp)] public sealed class InjectGenerator : AttributeWithTypeGenerator { + private const string LoggerFullName = "Microsoft.Extensions.Logging.ILogger"; + private const string LoggerFactoryFullName = "Microsoft.Extensions.Logging.ILoggerFactory"; + protected override string AttributeNamespace { get; } = $"{nameof(SecureFolderFS)}.Sdk.Attributes.InjectAttribute`1"; protected override string? GetCode(INamedTypeSymbol typeSymbol, ImmutableArray attributes) @@ -53,20 +56,45 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator } visibility = visibility == SyntaxKind.None ? SyntaxKind.PrivateKeyword : visibility; - name = string.IsNullOrEmpty(name) ? FormatName(type.Name) : name; - var backingFieldName = $"_{name}"; - - var injecteeField = GetFieldDeclaration(SyntaxKind.PrivateKeyword, backingFieldName, type.ToDisplayString(), true); - var injecteeProperty = GetPropertyDeclaration(visibility, name, type.ToDisplayString()).WithExpressionBody( - ArrowExpressionClause( - AssignmentExpression(SyntaxKind.CoalesceAssignmentExpression, - GetThisMemberAccessExpression(backingFieldName), - GetServiceRegistration(type, Constants.ServiceProviderName)))) - .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) - .AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(InjectGenerator))); - - members.Add(injecteeField); - members.Add(injecteeProperty); + + // Check if this is the special ILogger injection case + var isLoggerInjection = type is INamedTypeSymbol { Name: "ILogger", IsGenericType: false } && type.ContainingNamespace.ToDisplayString().Contains("Microsoft.Extensions.Logging"); + if (isLoggerInjection) + { + name = string.IsNullOrEmpty(name) ? "Logger" : name; + var backingFieldName = $"_{name}"; + var containingTypeName = typeSymbol.ToDisplayString(); + var loggerTypeName = $"global::Microsoft.Extensions.Logging.ILogger<{containingTypeName}>"; + + var loggerField = GetFieldDeclaration(SyntaxKind.PrivateKeyword, backingFieldName, loggerTypeName, true); + var loggerProperty = GetPropertyDeclaration(visibility, name, loggerTypeName).WithExpressionBody( + ArrowExpressionClause( + AssignmentExpression(SyntaxKind.CoalesceAssignmentExpression, + GetThisMemberAccessExpression(backingFieldName), + GetLoggerRegistration(containingTypeName, Constants.ServiceProviderName)))) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) + .AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(InjectGenerator))); + + members.Add(loggerField); + members.Add(loggerProperty); + } + else + { + name = string.IsNullOrEmpty(name) ? FormatName(type.Name) : name; + var backingFieldName = $"_{name}"; + + var injecteeField = GetFieldDeclaration(SyntaxKind.PrivateKeyword, backingFieldName, type.ToDisplayString(), true); + var injecteeProperty = GetPropertyDeclaration(visibility, name, type.ToDisplayString()).WithExpressionBody( + ArrowExpressionClause( + AssignmentExpression(SyntaxKind.CoalesceAssignmentExpression, + GetThisMemberAccessExpression(backingFieldName), + GetServiceRegistration(type, Constants.ServiceProviderName)))) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) + .AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(InjectGenerator))); + + members.Add(injecteeField); + members.Add(injecteeProperty); + } } if (members.Count > 0) diff --git a/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj b/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj index e8de2050c..ff35ac1c5 100644 --- a/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj +++ b/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj @@ -11,7 +11,7 @@ - + From 3528431d1b3012177f0eb37b10a9de84146091b9 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:04:46 +0200 Subject: [PATCH 34/79] Some improvements to iOS thumbnail loading --- .../AppModels/ImageStream.cs | 1 - .../ServiceImplementation/IOSMediaService.cs | 56 ++++++++++--------- .../Browser/BrowserControl.Rendering.xaml.cs | 4 +- src/Platforms/SecureFolderFS.UI/Constants.cs | 5 +- .../Views/Vault/BrowserViewModel.cs | 10 ++-- 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs index c1c04f375..04cc56602 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs @@ -1,5 +1,4 @@ using SecureFolderFS.Shared.ComponentModel; -using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.Streams; using IImage = SecureFolderFS.Shared.ComponentModel.IImage; diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs index 9467ebabf..6fe09ca85 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs @@ -2,6 +2,7 @@ using CoreGraphics; using CoreMedia; using Foundation; +using ImageIO; using OwlCore.Storage; using SecureFolderFS.Maui.AppModels; using SecureFolderFS.Maui.ServiceImplementation; @@ -51,33 +52,28 @@ private static async Task GenerateImageThumbnailAsync(Stream strea if (data is null) throw new Exception("Failed to load image data."); - using var image = UIImage.LoadFromData(data); - if (image?.CGImage is null) - throw new Exception("Failed to load image."); + using var source = CGImageSource.FromData(data); + if (source is null) + throw new Exception("Failed to create image source."); - // Apply EXIF orientation - var orientedImage = image.Orientation == UIImageOrientation.Up - ? image - : UIImage.FromImage(image.CGImage, 1.0f, image.Orientation); - - // Resize - var scale = Math.Min(maxSize / orientedImage.Size.Width, maxSize / orientedImage.Size.Height); - var newSize = new CGSize(orientedImage.Size.Width * scale, orientedImage.Size.Height * scale); - - UIGraphics.BeginImageContextWithOptions(newSize, false, 1.0f); - orientedImage.Draw(new CGRect(CGPoint.Empty, newSize)); - using var resizedImage = UIGraphics.GetImageFromCurrentImageContext(); - UIGraphics.EndImageContext(); - - if (resizedImage is null) - throw new Exception("Failed to resize image."); - - // Compress to JPEG - using var jpegData = resizedImage.AsJPEG(Constants.Browser.IMAGE_THUMBNAIL_QUALITY); + var options = new CGImageThumbnailOptions + { + MaxPixelSize = (int)maxSize, + ShouldAllowFloat = false, + CreateThumbnailWithTransform = true, // handles EXIF orientation + CreateThumbnailFromImageAlways = true + }; + + using var cgImage = source.CreateThumbnail(0, options); + if (cgImage is null) + throw new Exception("Failed to create thumbnail."); + + using var image = UIImage.FromImage(cgImage); + using var jpegData = image.AsJPEG(Constants.Browser.IMAGE_THUMBNAIL_QUALITY); if (jpegData is null) throw new FormatException("Failed to convert image to JPEG."); - var memoryStream = new MemoryStream(); + var memoryStream = new MemoryStream((int)jpegData.Length); await jpegData.AsStream().CopyToAsync(memoryStream).ConfigureAwait(false); memoryStream.Position = 0L; @@ -115,10 +111,18 @@ private static async Task GenerateVideoThumbnailAsync(Stream strea }; var actualTime = new CMTime((long)captureTime.TotalSeconds, 1); - var imageRef = generator.CopyCGImageAtTime(actualTime, out _, out var error); - if (imageRef is null || error != null) - throw new FormatException($"Failed to generate thumbnail: {error?.LocalizedDescription}"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var times = new[] { NSValue.FromCMTime(actualTime) }; + + generator.GenerateCGImagesAsynchronously(times, (_, image, _, _, error) => + { + if (error != null || image is null) + tcs.TrySetException(new FormatException($"Failed to generate thumbnail: {error?.LocalizedDescription}")); + else + tcs.TrySetResult(image); + }); + using var imageRef = await tcs.Task.ConfigureAwait(false); using var image = UIImage.FromImage(imageRef); using var jpegData = image.AsJPEG(Constants.Browser.IMAGE_THUMBNAIL_QUALITY); if (jpegData is null) diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs index 763b9c0d6..b2925d26b 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs @@ -104,8 +104,8 @@ private void EnqueueVisibleItemsForThumbnails() if (!_settingsService.UserSettings.AreThumbnailsEnabled || ItemsSource is null) return; - var items = ItemsSource.OfType().Where(f => f.CanLoadThumbnail()).ToList(); - if (items.Count == 0) + var items = ItemsSource.OfType().Where(f => f.CanLoadThumbnail()).ToArray(); + if (items.Length == 0) return; // Cancel any in-flight thumbnail work from the previous folder diff --git a/src/Platforms/SecureFolderFS.UI/Constants.cs b/src/Platforms/SecureFolderFS.UI/Constants.cs index 3755f4146..ecdd20576 100644 --- a/src/Platforms/SecureFolderFS.UI/Constants.cs +++ b/src/Platforms/SecureFolderFS.UI/Constants.cs @@ -43,9 +43,8 @@ public static class Application public static class Browser { public const int THUMBNAIL_MAX_PARALLELISATION = 4; - public const int IMAGE_THUMBNAIL_MAX_SIZE = 300; - public const int IMAGE_THUMBNAIL_QUALITY = 80; - public const int VIDEO_THUMBNAIL_QUALITY = 80; + public const int IMAGE_THUMBNAIL_MAX_SIZE = 296; + public const int IMAGE_THUMBNAIL_QUALITY = 75; } public static class FileData diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs index a97ed8149..4df611f6b 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs @@ -274,14 +274,14 @@ protected virtual async Task NewItemAsync(string? itemType, CancellationToken ca case "File": { var file = await modifiableFolder.CreateFileAsync(formattedName, false, cancellationToken); - CurrentFolder.Items.Insert(new FileViewModel(file, this, CurrentFolder), Layouts.GetSorter()); + CurrentFolder.Items.Insert(new FileViewModel(file, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); break; } case "Folder": { var folder = await modifiableFolder.CreateFolderAsync(formattedName, false, cancellationToken); - CurrentFolder.Items.Insert(new FolderViewModel(folder, this, CurrentFolder), Layouts.GetSorter()); + CurrentFolder.Items.Insert(new FolderViewModel(folder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); break; } } @@ -328,7 +328,7 @@ await TransferViewModel.TransferAsync([ file ], async (item, token) => var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); // Add to destination - CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder), Layouts.GetSorter()); + CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); }, cts.Token); break; @@ -351,7 +351,7 @@ await TransferViewModel.TransferAsync([ folder ], async (item, reporter, token) var copiedFolder = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, reporter, token); // Add to destination - CurrentFolder.Items.Insert(new FolderViewModel(copiedFolder, this, CurrentFolder), Layouts.GetSorter()); + CurrentFolder.Items.Insert(new FolderViewModel(copiedFolder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); }, cts.Token); break; @@ -374,7 +374,7 @@ await TransferViewModel.TransferAsync(galleryItems, async (item, token) => var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); // Add to destination - CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder), Layouts.GetSorter()); + CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); }, cts.Token); break; From 6e2d9e6edfcb11477ddd294c80cd3ff40c854055 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:33:01 +0200 Subject: [PATCH 35/79] Avoid double thumbnail cache key resolving --- .../AppModels/ThumbnailCacheModel.cs | 14 +++++--------- .../Controls/Storage/Browser/FileViewModel.cs | 11 +++++------ .../Storage/Browser/SearchBrowserItemViewModel.cs | 5 +++-- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs index 902f385d4..5f95f9af6 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs @@ -45,16 +45,14 @@ public ThumbnailCacheModel(int maxEntries) /// Tries to get a cached thumbnail for the specified file. /// The cache key includes the file's modification date, so modified files automatically get new thumbnails. /// - /// The file to get the cached thumbnail for. + /// A unique cache ID. /// A that cancels this action. /// A that represents the asynchronous operation. Value is the cached thumbnail stream if found, otherwise null. - public async Task TryGetCachedThumbnailAsync(IFile file, CancellationToken cancellationToken = default) + public async Task TryGetCachedThumbnailAsync(string cacheKey, CancellationToken cancellationToken = default) { try { - var cacheKey = await GetCacheKeyAsync(file, cancellationToken); var cachedData = await _database.GetValueAsync(cacheKey, cancellationToken: cancellationToken); - if (cachedData is null || cachedData.Length == 0) return null; @@ -70,16 +68,14 @@ public ThumbnailCacheModel(int maxEntries) /// Caches the thumbnail for the specified file. /// The cache key includes the file's modification date, ensuring modified files get fresh thumbnails. /// - /// The file to cache the thumbnail for. + /// A unique cache ID. /// The thumbnail stream to cache. /// A that cancels this action. /// A that represents the asynchronous operation. - public async Task CacheThumbnailAsync(IFile file, IImageStream thumbnailStream, CancellationToken cancellationToken = default) + public async Task CacheThumbnailAsync(string cacheKey, IImageStream thumbnailStream, CancellationToken cancellationToken = default) { try { - var cacheKey = await GetCacheKeyAsync(file, cancellationToken); - // Copy thumbnail to byte array using var memoryStream = new MemoryStream(); await thumbnailStream.CopyToAsync(memoryStream, cancellationToken); @@ -111,7 +107,7 @@ public Task ClearCacheAsync(CancellationToken cancellationToken = default) /// The file to generate a cache key for. /// A that cancels this action. /// A unique cache key string. - private static async Task GetCacheKeyAsync(IFile file, CancellationToken cancellationToken) + public static async Task GetCacheKeyAsync(IFile file, CancellationToken cancellationToken) { var pathHash = GetPathHash(file.Id); var dateModified = await file.GetDateModifiedAsync(cancellationToken); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs index e34f67eaa..3e2450413 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using OwlCore.Storage; +using SecureFolderFS.Sdk.AppModels; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.Extensions; @@ -50,14 +51,12 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau { Thumbnail?.Dispose(); - if (!SettingsService.UserSettings.AreThumbnailsEnabled) - return; - - if (!CanLoadThumbnail()) + if (!SettingsService.UserSettings.AreThumbnailsEnabled || !CanLoadThumbnail()) return; // Try to get from the cache first - var cachedStream = await BrowserViewModel.ThumbnailCache.TryGetCachedThumbnailAsync(File, cancellationToken); + var cacheKey = await ThumbnailCacheModel.GetCacheKeyAsync(File, cancellationToken); + var cachedStream = await BrowserViewModel.ThumbnailCache.TryGetCachedThumbnailAsync(cacheKey, cancellationToken); if (cachedStream is not null) { Thumbnail = new StreamImageModel(cachedStream); @@ -72,7 +71,7 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau // Show and cache the generated thumbnail Thumbnail = generatedThumbnail; - _ = BrowserViewModel.ThumbnailCache.CacheThumbnailAsync(File, generatedThumbnail, cancellationToken); + _ = BrowserViewModel.ThumbnailCache.CacheThumbnailAsync(cacheKey, generatedThumbnail, cancellationToken); } /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs index fa9d46571..dfb46110e 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs @@ -67,7 +67,8 @@ public async Task InitAsync(CancellationToken cancellationToken = default) if (!CanLoadThumbnail() || Inner is not IFile file || Classification is not { TypeHint: var typeHint }) return; - var cachedStream = await _thumbnailCache.TryGetCachedThumbnailAsync(file, cancellationToken).ConfigureAwait(false); + var cacheKey = await ThumbnailCacheModel.GetCacheKeyAsync(file, cancellationToken).ConfigureAwait(false); + var cachedStream = await _thumbnailCache.TryGetCachedThumbnailAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (cachedStream is not null) { await _uiContext.PostOrExecuteAsync(() => @@ -89,7 +90,7 @@ await _uiContext.PostOrExecuteAsync(() => return Task.CompletedTask; }); - _ = _thumbnailCache.CacheThumbnailAsync(file, generatedThumbnail, cancellationToken); + _ = _thumbnailCache.CacheThumbnailAsync(cacheKey, generatedThumbnail, cancellationToken); } public bool CanLoadThumbnail() From accb60ffe4b2a9bb877b17a08ee09c215aca6b07 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:43:43 +0200 Subject: [PATCH 36/79] Remove CopyToAsync from IImageStream --- .../AppModels/ImageStream.cs | 40 ------------------- .../AppModels/ImageStreamSource.cs | 33 +++++++++++++++ .../AndroidMediaService.cs | 4 +- .../ServiceImplementation/IOSMediaService.cs | 4 +- .../BaseMauiMediaService.cs | 2 +- .../ValueConverters/FileIconConverter.cs | 10 ++--- .../ValueConverters/ImageToSourceConverter.cs | 2 +- .../ValueConverters/ImageToSourceConverter.cs | 2 +- .../AppModels/ThumbnailCacheModel.cs | 12 ++++-- .../ComponentModel/IImageStream.cs | 8 ++-- .../Models/StreamImageModel.cs | 30 ++++---------- 11 files changed, 64 insertions(+), 83 deletions(-) delete mode 100644 src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs create mode 100644 src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs deleted file mode 100644 index 04cc56602..000000000 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs +++ /dev/null @@ -1,40 +0,0 @@ -using SecureFolderFS.Shared.ComponentModel; -using SecureFolderFS.Storage.Streams; -using IImage = SecureFolderFS.Shared.ComponentModel.IImage; - -namespace SecureFolderFS.Maui.AppModels -{ - /// - internal sealed class ImageStream : IImageStream - { - public Stream Stream { get; } - - public StreamImageSource Source { get; } - - public ImageStream(Stream stream) - { - Stream = stream; - Source = new(); - Source.Stream = _ => Task.FromResult(stream); - } - - /// - public async Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default) - { - var savedPosition = Stream.Position; - await Stream.CopyToAsync(destination, cancellationToken); - - if (Stream.CanSeek) - Stream.Position = savedPosition; - } - - /// - public void Dispose() - { - if (Stream is NonDisposableStream nonDisposableStream) - nonDisposableStream.ForceClose(); - else - Stream.Dispose(); - } - } -} diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs new file mode 100644 index 000000000..d5bf5e13c --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs @@ -0,0 +1,33 @@ +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Storage.Streams; + +namespace SecureFolderFS.Maui.AppModels +{ + /// + internal sealed class ImageStreamSource : IImageStream + { + /// + /// Gets the streamed image source. + /// + public StreamImageSource Source { get; } + + /// + public Stream Inner { get; } + + public ImageStreamSource(Stream inner) + { + Inner = inner; + Source = new(); + Source.Stream = _ => Task.FromResult(inner); + } + + /// + public void Dispose() + { + if (Inner is NonDisposableStream nonDisposableStream) + nonDisposableStream.ForceClose(); + else + Inner.Dispose(); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs index 014f23153..14bc7d122 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs @@ -28,7 +28,7 @@ public override async Task GenerateThumbnailAsync(IFile file, Type 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); + return new ImageStreamSource(imageStream); } case TypeHint.Media: @@ -36,7 +36,7 @@ public override async Task GenerateThumbnailAsync(IFile file, Type await using var stream = await file.OpenReadAsync(cancellationToken).ConfigureAwait(false); var imageStream = await GenerateVideoThumbnailAsync(stream, TimeSpan.FromSeconds(0)).ConfigureAwait(false); - return new ImageStream(imageStream); + return new ImageStreamSource(imageStream); } default: throw new InvalidOperationException("The provided file type is invalid."); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs index 6fe09ca85..0a1d7f843 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs @@ -77,7 +77,7 @@ private static async Task GenerateImageThumbnailAsync(Stream strea await jpegData.AsStream().CopyToAsync(memoryStream).ConfigureAwait(false); memoryStream.Position = 0L; - return new ImageStream(new NonDisposableStream(memoryStream)); + return new ImageStreamSource(new NonDisposableStream(memoryStream)); } private static async Task GenerateVideoThumbnailAsync(Stream stream, string extension, TimeSpan captureTime) @@ -132,7 +132,7 @@ private static async Task GenerateVideoThumbnailAsync(Stream strea await jpegData.AsStream().CopyToAsync(memoryStream).ConfigureAwait(false); memoryStream.Position = 0L; - return new ImageStream(new NonDisposableStream(memoryStream)); + return new ImageStreamSource(new NonDisposableStream(memoryStream)); } finally { diff --git a/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs index 97f63d81b..71d52ff63 100644 --- a/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs +++ b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs @@ -51,7 +51,7 @@ public virtual Task GetImageFromUrlAsync(string url, CancellationToken c public virtual async Task ReadImageFileAsync(IFile file, CancellationToken cancellationToken) { var stream = await file.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken); - return new ImageStream(stream); + return new ImageStreamSource(stream); } /// diff --git a/src/Platforms/SecureFolderFS.Maui/ValueConverters/FileIconConverter.cs b/src/Platforms/SecureFolderFS.Maui/ValueConverters/FileIconConverter.cs index 0b8489304..64650be82 100644 --- a/src/Platforms/SecureFolderFS.Maui/ValueConverters/FileIconConverter.cs +++ b/src/Platforms/SecureFolderFS.Maui/ValueConverters/FileIconConverter.cs @@ -47,14 +47,14 @@ internal sealed class FileIconConverter : IValueConverter { switch (image) { - case StreamImageModel { Stream.CanRead: true } streamImageModel: + case StreamImageModel { Inner.CanRead: true } streamImageModel: { - streamImageModel.Stream.TrySetPositionOrAdvance(0L); + streamImageModel.Inner.TrySetPositionOrAdvance(0L); return new Image() { Source = new StreamImageSource() { - Stream = _ => Task.FromResult(streamImageModel.Stream) + Stream = _ => Task.FromResult(streamImageModel.Inner) }, Aspect = Aspect.AspectFill, HorizontalOptions = LayoutOptions.Fill, @@ -62,9 +62,9 @@ internal sealed class FileIconConverter : IValueConverter }; } - case ImageStream { Stream.CanRead: true } imageStream: + case ImageStreamSource { Inner.CanRead: true } imageStream: { - imageStream.Stream.TrySetPositionOrAdvance(0L); + imageStream.Inner.TrySetPositionOrAdvance(0L); return new Image() { Source = imageStream.Source, diff --git a/src/Platforms/SecureFolderFS.Maui/ValueConverters/ImageToSourceConverter.cs b/src/Platforms/SecureFolderFS.Maui/ValueConverters/ImageToSourceConverter.cs index bec19b898..11160e958 100644 --- a/src/Platforms/SecureFolderFS.Maui/ValueConverters/ImageToSourceConverter.cs +++ b/src/Platforms/SecureFolderFS.Maui/ValueConverters/ImageToSourceConverter.cs @@ -14,7 +14,7 @@ internal sealed class ImageToSourceConverter : IValueConverter { return value switch { - ImageStream imageStream => imageStream.Source, + ImageStreamSource imageStream => imageStream.Source, ImageIcon iconImage => new FontImageSource() { Glyph = GetDescription(iconImage.MauiIcon.Icon), diff --git a/src/Platforms/SecureFolderFS.Uno/ValueConverters/ImageToSourceConverter.cs b/src/Platforms/SecureFolderFS.Uno/ValueConverters/ImageToSourceConverter.cs index 507f25edd..ae9cf95fa 100644 --- a/src/Platforms/SecureFolderFS.Uno/ValueConverters/ImageToSourceConverter.cs +++ b/src/Platforms/SecureFolderFS.Uno/ValueConverters/ImageToSourceConverter.cs @@ -15,7 +15,7 @@ public sealed class ImageToSourceConverter : IValueConverter return value switch { ImageBitmap imageBitmap => imageBitmap.Source, - StreamImageModel imageStream => StreamToImageSource(imageStream.Stream), + StreamImageModel imageStream => StreamToImageSource(imageStream.Inner), ImageResource resourceImage => new BitmapImage(resourceImage.IsResource ? new Uri($"ms-appx:///{resourceImage.Name}") : new Uri(resourceImage.Name)), diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs index 5f95f9af6..f894b71ab 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs @@ -56,7 +56,7 @@ public ThumbnailCacheModel(int maxEntries) if (cachedData is null || cachedData.Length == 0) return null; - return new MemoryStream(cachedData); + return new MemoryStream(cachedData, writable: false); } catch { @@ -77,9 +77,13 @@ public async Task CacheThumbnailAsync(string cacheKey, IImageStream thumbnailStr try { // Copy thumbnail to byte array - using var memoryStream = new MemoryStream(); - await thumbnailStream.CopyToAsync(memoryStream, cancellationToken); - var data = memoryStream.ToArray(); + var data = new byte[thumbnailStream.Inner.Length]; + var savedPosition = thumbnailStream.Inner.Position; + thumbnailStream.Inner.Position = 0L; + var read = await thumbnailStream.Inner.ReadAsync(data, cancellationToken); + thumbnailStream.Inner.Position = savedPosition; + if (read != data.Length) + return; await _database.SetValueAsync(cacheKey, data, cancellationToken); } diff --git a/src/Shared/SecureFolderFS.Shared/ComponentModel/IImageStream.cs b/src/Shared/SecureFolderFS.Shared/ComponentModel/IImageStream.cs index b50d130b7..c110fd8a3 100644 --- a/src/Shared/SecureFolderFS.Shared/ComponentModel/IImageStream.cs +++ b/src/Shared/SecureFolderFS.Shared/ComponentModel/IImageStream.cs @@ -1,11 +1,11 @@ using System.IO; -using System.Threading; -using System.Threading.Tasks; namespace SecureFolderFS.Shared.ComponentModel { - public interface IImageStream : IImage + /// + /// Represents an image that can be read from a . + /// + public interface IImageStream : IImage, IWrapper { - Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default); } } diff --git a/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs b/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs index e6366be99..be3ad23a1 100644 --- a/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs +++ b/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs @@ -1,43 +1,27 @@ using System.IO; -using System.Threading; -using System.Threading.Tasks; using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Shared.Models { - /// - /// A simple implementation of that wraps a . - /// + /// public sealed class StreamImageModel : IImageStream { - /// - /// Gets the stream containing image data. - /// - public Stream Stream { get; } + /// + public Stream Inner { get; } /// /// Initializes a new instance of the class. /// - /// The stream containing image data. - public StreamImageModel(Stream stream) - { - Stream = stream; - } - - /// - public async Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default) + /// The stream containing image data. + public StreamImageModel(Stream inner) { - var savedPosition = Stream.Position; - await Stream.CopyToAsync(destination, cancellationToken); - - if (Stream.CanSeek) - Stream.Position = savedPosition; + Inner = inner; } /// public void Dispose() { - Stream.Dispose(); + Inner.Dispose(); } } } From b5b3b85027f3273911bc5acbc3e7e2b2599b85c4 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:49:36 +0200 Subject: [PATCH 37/79] Update BrowserControl.Rendering.xaml.cs --- .../Browser/BrowserControl.Rendering.xaml.cs | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs index b2925d26b..36ce90943 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs @@ -115,22 +115,32 @@ private void EnqueueVisibleItemsForThumbnails() _ = Task.Run(async () => { - var tasks = items.Select(async item => + var tasks = new List(); + foreach (var item in items) { + if (ct.IsCancellationRequested) + break; + await _thumbnailSemaphore.WaitAsync(ct); - try - { - await item.InitAsync(ct); - } - catch (OperationCanceledException) + tasks.Add(Task.Run(async () => { - // Navigation occurred, stop quietly - } - finally - { - _thumbnailSemaphore.Release(); - } - }); + try + { + await item.InitAsync(ct); + } + catch (OperationCanceledException) + { + // Navigation occurred, stop quietly + } + finally + { + _thumbnailSemaphore.Release(); + } + }, ct)); + + // Clean up completed tasks + tasks.RemoveAll(t => t.IsCompleted); + } await Task.WhenAll(tasks); }, ct); From 6ea749174b5778edec074f1a2539a4b2782d7674 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:18:13 +0200 Subject: [PATCH 38/79] Removed EnqueueVisibleItemsForThumbnails --- .../Browser/BrowserControl.Rendering.xaml.cs | 57 ++----------------- .../Browser/BrowserControl.xaml.cs | 12 +++- .../Controls/Storage/Browser/FileViewModel.cs | 33 ++++++++++- 3 files changed, 45 insertions(+), 57 deletions(-) diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs index 36ce90943..527dc36ab 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs @@ -92,60 +92,10 @@ public async Task ReloadCollectionViewAsync() // Update our reference _collectionView = newCollectionView; - // Kick off thumbnail loading immediately - EnqueueVisibleItemsForThumbnails(); - // Fade in await _collectionView.FadeToAsync(1, 100); } - private void EnqueueVisibleItemsForThumbnails() - { - if (!_settingsService.UserSettings.AreThumbnailsEnabled || ItemsSource is null) - return; - - var items = ItemsSource.OfType().Where(f => f.CanLoadThumbnail()).ToArray(); - if (items.Length == 0) - return; - - // Cancel any in-flight thumbnail work from the previous folder - _thumbnailCts?.Cancel(); - _thumbnailCts = new CancellationTokenSource(); - var ct = _thumbnailCts.Token; - - _ = Task.Run(async () => - { - var tasks = new List(); - foreach (var item in items) - { - if (ct.IsCancellationRequested) - break; - - await _thumbnailSemaphore.WaitAsync(ct); - tasks.Add(Task.Run(async () => - { - try - { - await item.InitAsync(ct); - } - catch (OperationCanceledException) - { - // Navigation occurred, stop quietly - } - finally - { - _thumbnailSemaphore.Release(); - } - }, ct)); - - // Clean up completed tasks - tasks.RemoveAll(t => t.IsCompleted); - } - - await Task.WhenAll(tasks); - }, ct); - } - private void TryEnqueueThumbnail(object? sender) { if (!_settingsService.UserSettings.AreThumbnailsEnabled) @@ -157,8 +107,6 @@ private void TryEnqueueThumbnail(object? sender) if (!fileViewModel.CanLoadThumbnail()) return; - // Reuse the current folder's cancellation token, so virtualized - // items are also canceled on navigation var ct = _thumbnailCts?.Token ?? CancellationToken.None; _ = Task.Run(async () => { @@ -167,7 +115,10 @@ private void TryEnqueueThumbnail(object? sender) { await fileViewModel.InitAsync(ct); } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + // Navigation occurred or load was canceled + } finally { _thumbnailSemaphore.Release(); diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml.cs index 9df1847fb..b5f59a83b 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml.cs @@ -59,7 +59,17 @@ public IList? ItemsSource set => SetValue(ItemsSourceProperty, value); } public static readonly BindableProperty ItemsSourceProperty = - BindableProperty.Create(nameof(ItemsSource), typeof(IList), typeof(BrowserControl), defaultValue: null); + BindableProperty.Create(nameof(ItemsSource), typeof(IList), typeof(BrowserControl), defaultValue: null, + propertyChanged: static (bindable, oldValue, newValue) => + { + if (bindable is not BrowserControl control || ReferenceEquals(oldValue, newValue)) + return; + + // Cancel any in-flight thumbnail work from the previous folder + control._thumbnailCts?.Cancel(); + control._thumbnailCts?.Dispose(); + control._thumbnailCts = new CancellationTokenSource(); + }); public BrowserViewType ViewType { diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs index 3e2450413..f86531b89 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs @@ -1,4 +1,5 @@ -using System.ComponentModel; +using System; +using System.ComponentModel; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -22,6 +23,8 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser [Bindable(true)] public partial class FileViewModel : BrowserItemViewModel { + private Task? _thumbnailLoadingTask; + /// public override IStorable Inner => File; @@ -48,10 +51,33 @@ public FileViewModel(IFile file, BrowserViewModel browserViewModel, FolderViewMo /// public override async Task InitAsync(CancellationToken cancellationToken = default) + { + if (!SettingsService.UserSettings.AreThumbnailsEnabled || !CanLoadThumbnail()) + return; + + // Deduplicate concurrent calls to prevent duplicate thumbnail generation + if (_thumbnailLoadingTask != null && !_thumbnailLoadingTask.IsCompleted) + { + try + { + await _thumbnailLoadingTask; + return; + } + catch (OperationCanceledException) + { + // The previous load was canceled (e.g. navigation) - start a fresh one with the new token + } + } + + _thumbnailLoadingTask = PerformThumbnailLoadAsync(cancellationToken); + await _thumbnailLoadingTask; + } + + private async Task PerformThumbnailLoadAsync(CancellationToken cancellationToken) { Thumbnail?.Dispose(); - if (!SettingsService.UserSettings.AreThumbnailsEnabled || !CanLoadThumbnail()) + if (!CanLoadThumbnail()) return; // Try to get from the cache first @@ -63,8 +89,9 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau return; } - // Generate a new thumbnail cancellationToken.ThrowIfCancellationRequested(); + + // Generate a new thumbnail var generatedThumbnail = await MediaService.TryGenerateThumbnailAsync(File, Classification.TypeHint, cancellationToken); if (generatedThumbnail is null) return; From fdc6abaa8492cf84f0bbc89813da021fd54372b6 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:25:46 +0200 Subject: [PATCH 39/79] Increase DEFAULT_MAX_ENTRIES cache size --- src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs | 2 +- src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs index f894b71ab..7d0374bf7 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs @@ -20,7 +20,7 @@ public sealed class ThumbnailCacheModel : IDisposable /// /// Gets the default maximum number of cached thumbnails. /// - public const int DEFAULT_MAX_ENTRIES = 100; + public const int DEFAULT_MAX_ENTRIES = 150; private readonly IDatabaseModel _database; diff --git a/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs b/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs index be3ad23a1..524b1a4ac 100644 --- a/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs +++ b/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs @@ -25,5 +25,3 @@ public void Dispose() } } } - - From 94e1645e68f2e63ca1935979d864ff64e1f33ac9 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:58:41 +0200 Subject: [PATCH 40/79] More thumbnail optimizations --- .../AppModels/ImageStreamSource.cs | 11 +- .../Popups/PropertiesPopup.xaml | 9 +- .../Browser/BrowserControl.Rendering.xaml.cs | 14 +- .../UserControls/Browser/BrowserControl.xaml | 58 ++++++--- .../ValueConverters/FileIconConverter.cs | 122 ++++++++++-------- .../Modals/Vault/BrowserSearchModalPage.xaml | 4 +- .../Modals/Vault/RecycleBinModalPage.xaml | 4 +- .../Storage/Browser/BrowserItemViewModel.cs | 4 + .../Controls/Storage/Browser/FileViewModel.cs | 29 +++-- .../Storage/Browser/FolderViewModel.cs | 6 +- 10 files changed, 156 insertions(+), 105 deletions(-) diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs index d5bf5e13c..62c313e43 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs @@ -1,4 +1,5 @@ using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Storage.Streams; namespace SecureFolderFS.Maui.AppModels @@ -17,8 +18,14 @@ internal sealed class ImageStreamSource : IImageStream public ImageStreamSource(Stream inner) { Inner = inner; - Source = new(); - Source.Stream = _ => Task.FromResult(inner); + Source = new StreamImageSource + { + Stream = _ => + { + Inner.TrySetPositionOrAdvance(0L); + return Task.FromResult(Inner); + } + }; } /// diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml b/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml index 2d9df0c70..1b2efe8be 100644 --- a/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml @@ -48,14 +48,15 @@ - + WidthRequest="40" /> diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs index 527dc36ab..788ec8b60 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs @@ -2,6 +2,7 @@ using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Maui.UserControls.Browser { @@ -96,15 +97,12 @@ public async Task ReloadCollectionViewAsync() await _collectionView.FadeToAsync(1, 100); } - private void TryEnqueueThumbnail(object? sender) + private void TryEnqueueItem(object? sender) { if (!_settingsService.UserSettings.AreThumbnailsEnabled) return; - if (sender is not BindableObject { BindingContext: FileViewModel fileViewModel }) - return; - - if (!fileViewModel.CanLoadThumbnail()) + if (sender is not BindableObject { BindingContext: IAsyncInitialize asyncInitialize }) return; var ct = _thumbnailCts?.Token ?? CancellationToken.None; @@ -113,7 +111,7 @@ private void TryEnqueueThumbnail(object? sender) await _thumbnailSemaphore.WaitAsync(ct); try { - await fileViewModel.InitAsync(ct); + await asyncInitialize.InitAsync(ct); } catch (OperationCanceledException) { @@ -137,7 +135,7 @@ private void TryEnqueueThumbnail(object? sender) private void ItemContainer_Loaded(object? sender, EventArgs e) { - TryEnqueueThumbnail(sender); + TryEnqueueItem(sender); RegisterItemContainerPanGesture(sender); if (sender is View view) @@ -148,7 +146,7 @@ private void ItemContainer_BindingContextChanged(object? sender, EventArgs e) { #if IOS // Also handle BindingContextChanged for virtualized/recycled items on iOS - TryEnqueueThumbnail(sender); + TryEnqueueItem(sender); #endif } diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml index ff6efa508..66a070cb4 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml @@ -51,7 +51,7 @@ @@ -61,19 +61,43 @@ - + WidthRequest="40" /> - @@ -133,11 +157,11 @@ - + IsOpaque="True" + Source="{Binding Thumbnail, Converter={StaticResource FileIconConverter}, ConverterParameter={x:Reference SourceGrid}}" /> + internal sealed class FileIconConverter : IValueConverter { /// @@ -18,17 +23,19 @@ internal sealed class FileIconConverter : IValueConverter if (parameter is not View { BindingContext: IWrapper storableWrapper }) return ImageSource.FromFile(GetDefaultFileIcon()); - // Thumbnail loaded → return optimized ImageSource + // Thumbnail loaded - return optimized ImageSource if (value is IImage image) return FromImage(image); - // Fallback icon (folder, file, PDF, archive…) + // Fallback icon (folder, file, PDF, archive, etc.) return GetFallbackImageSource(storableWrapper); } /// public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - => throw new NotImplementedException(); + { + throw new NotImplementedException(); + } private static ImageSource FromImage(IImage image) { diff --git a/src/Platforms/SecureFolderFS.Maui/ValueConverters/ThumbnailToAspectConverter.cs b/src/Platforms/SecureFolderFS.Maui/ValueConverters/ThumbnailToAspectConverter.cs new file mode 100644 index 000000000..fa39a97c7 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/ValueConverters/ThumbnailToAspectConverter.cs @@ -0,0 +1,24 @@ +using System.Globalization; +using IImage = SecureFolderFS.Shared.ComponentModel.IImage; + +namespace SecureFolderFS.Maui.ValueConverters +{ + /// + /// Returns AspectFill when a real thumbnail (IImage) is present, + /// otherwise AspectFit for static fallback icons (folder/file/PDF/etc.). + /// + internal sealed class ThumbnailToAspectConverter : IValueConverter + { + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is IImage ? Aspect.AspectFill : Aspect.AspectFit; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/BrowserSearchModalPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/BrowserSearchModalPage.xaml index 8138bf400..33f193640 100644 --- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/BrowserSearchModalPage.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/BrowserSearchModalPage.xaml @@ -68,6 +68,7 @@ ColumnSpacing="12"> Date: Sat, 4 Apr 2026 01:53:29 +0200 Subject: [PATCH 42/79] Improved the design of HealthPage --- .../Resources/Styles/Converters.xaml | 1 + .../UserControls/Widgets/HealthWidget.xaml | 5 - .../SecureFolderFS.Maui/Views/MainPage.xaml | 21 +-- .../Views/Vault/HealthPage.xaml | 139 +++++++++++++++--- .../Views/Vault/HealthPage.xaml.cs | 90 +++++++++++- .../Dialogs/LicensesDialog.xaml | 16 +- .../Vault/VaultHealthViewModel.Scanning.cs | 2 +- 7 files changed, 221 insertions(+), 53 deletions(-) diff --git a/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml b/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml index 7ef12c23b..d40bf987c 100644 --- a/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml @@ -15,6 +15,7 @@ + diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Widgets/HealthWidget.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/Widgets/HealthWidget.xaml index f18e4e107..2694d7c43 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Widgets/HealthWidget.xaml +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Widgets/HealthWidget.xaml @@ -6,13 +6,8 @@ xmlns:mi="http://www.aathifmahir.com/dotnet/2022/maui/icons" xmlns:mi_cupertino="clr-namespace:MauiIcons.Cupertino;assembly=MauiIcons.Cupertino" xmlns:mi_material="clr-namespace:MauiIcons.Material;assembly=MauiIcons.Material" - xmlns:vc="clr-namespace:SecureFolderFS.Maui.ValueConverters" x:Name="RootControl"> - - - - - - - + + +