From f82fda523d3cf56dcda6e57cf56bff376224768b Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 00:03:25 +0200
Subject: [PATCH 01/55] Added the option to increase WebDAV size limit
---
.../WebDavWrapper.cs | 50 ++++++++--
.../Helpers/WebDavRedirectorHelpers.cs | 92 +++++++++++++++++++
.../Settings/PreferencesSettingsPage.xaml | 7 ++
.../Settings/PreferencesSettingsPage.xaml.cs | 41 +++++++++
4 files changed, 180 insertions(+), 10 deletions(-)
create mode 100644 src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs
diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs
index 267175b94..9866dd57d 100644
--- a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs
+++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs
@@ -1,7 +1,7 @@
-using NWebDav.Server.Dispatching;
+using Microsoft.Extensions.Logging;
+using NWebDav.Server.Dispatching;
using SecureFolderFS.Core.WebDav.Helpers;
using System;
-using System.Diagnostics;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
@@ -10,6 +10,8 @@ namespace SecureFolderFS.Core.WebDav
{
public sealed class WebDavWrapper
{
+ private const int ERROR_OPERATION_ABORTED = 995;
+
private Thread? _fsThead;
private readonly HttpListener _httpListener;
private readonly IRequestDispatcher _requestDispatcher;
@@ -36,17 +38,45 @@ private async Task EnsureFileSystemAsync()
try
{
_httpListener.Start();
- while (!_fileSystemCts.IsCancellationRequested && (await _httpListener.GetContextAsync() is var httpListenerContext))
- {
- if (httpListenerContext.Request.IsAuthenticated)
- Debugger.Break();
-
- await _requestDispatcher.DispatchRequestAsync(httpListenerContext, _fileSystemCts.Token);
- }
}
catch (Exception ex)
{
- _ = ex;
+ _requestDispatcher.Logger?.LogError(ex, "Failed to start the WebDAV HTTP listener.");
+ return;
+ }
+
+ while (!_fileSystemCts.IsCancellationRequested)
+ {
+ HttpListenerContext httpListenerContext;
+ try
+ {
+ httpListenerContext = await _httpListener.GetContextAsync();
+ }
+ catch (Exception ex) when (ex is ObjectDisposedException or HttpListenerException { ErrorCode: ERROR_OPERATION_ABORTED })
+ {
+ // The listener was closed, stop accepting requests
+ break;
+ }
+ catch (Exception ex)
+ {
+ // A transient error while accepting a request must not kill the accept loop
+ _requestDispatcher.Logger?.LogError(ex, "Failed to accept an incoming WebDAV request.");
+ continue;
+ }
+
+ // Dispatch each request concurrently. The Windows WebDAV redirector interleaves requests
+ // (PROPFIND, LOCK refreshes) with long-running transfers and times out when they stall.
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _requestDispatcher.DispatchRequestAsync(httpListenerContext, _fileSystemCts.Token);
+ }
+ catch (Exception ex)
+ {
+ _requestDispatcher.Logger?.LogError(ex, "Unhandled exception while dispatching a WebDAV request.");
+ }
+ }, _fileSystemCts.Token);
}
}
diff --git a/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs b/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs
new file mode 100644
index 000000000..c0d15dfab
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Threading.Tasks;
+#if WINDOWS
+using System.Diagnostics;
+using Microsoft.Win32;
+#endif
+
+namespace SecureFolderFS.Uno.Helpers
+{
+ ///
+ /// Helpers for interacting with the Windows WebDAV redirector (WebClient service) configuration.
+ ///
+ public static class WebDavRedirectorHelpers
+ {
+ ///
+ /// The maximum value accepted by the WebDAV redirector for the file size limit (about 4GB).
+ ///
+ public const uint MAX_FILE_SIZE_LIMIT = uint.MaxValue;
+
+#if WINDOWS
+ private const uint DEFAULT_FILE_SIZE_LIMIT = 50_000_000u;
+ private const string WEBCLIENT_PARAMETERS_KEY = @"SYSTEM\CurrentControlSet\Services\WebClient\Parameters";
+ private const string FILE_SIZE_LIMIT_VALUE = "FileSizeLimitInBytes";
+#endif
+
+ ///
+ /// Gets the maximum size in bytes that the WebDAV redirector allows for file transfers.
+ ///
+ /// The configured limit in bytes, or null if it could not be determined.
+ public static uint? GetFileSizeLimit()
+ {
+#if WINDOWS
+ try
+ {
+ using var parametersKey = Registry.LocalMachine.OpenSubKey(WEBCLIENT_PARAMETERS_KEY);
+ return parametersKey?.GetValue(FILE_SIZE_LIMIT_VALUE) switch
+ {
+ int value => unchecked((uint)value),
+
+ // The WebDAV redirector falls back to the default limit when the value is not present
+ null => DEFAULT_FILE_SIZE_LIMIT,
+ _ => null
+ };
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+#else
+ return null;
+#endif
+ }
+
+ ///
+ /// Attempts to raise the WebDAV redirector file size limit to the maximum allowed value.
+ ///
+ ///
+ /// Modifying the limit requires elevation - the user is presented with a UAC prompt.
+ /// The new limit takes effect once the WebClient service is restarted.
+ ///
+ /// A that represents the asynchronous operation. Value is true if the limit was applied, otherwise false.
+ public static async Task TrySetMaxFileSizeLimitAsync()
+ {
+#if WINDOWS
+ try
+ {
+ using var process = Process.Start(new ProcessStartInfo()
+ {
+ FileName = "reg.exe",
+ Arguments = $@"add HKLM\{WEBCLIENT_PARAMETERS_KEY} /v {FILE_SIZE_LIMIT_VALUE} /t REG_DWORD /d {MAX_FILE_SIZE_LIMIT} /f",
+ UseShellExecute = true,
+ Verb = "runas",
+ WindowStyle = ProcessWindowStyle.Hidden
+ });
+
+ if (process is null)
+ return false;
+
+ await process.WaitForExitAsync();
+ return process.ExitCode == 0;
+ }
+ catch (Exception)
+ {
+ // The user most likely declined the elevation prompt
+ return false;
+ }
+#else
+ return await Task.FromResult(false);
+#endif
+ }
+ }
+}
diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml
index 48c7fb3ec..0ed0e7867 100644
--- a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml
@@ -54,6 +54,13 @@
Message="{x:Bind ViewModel.BannerViewModel.FileSystemInfoBar.Message, Mode=OneWay}"
Severity="{x:Bind ViewModel.BannerViewModel.FileSystemInfoBar.Severity, Mode=OneWay, Converter={StaticResource GenericEnumConverter}}"
Visibility="{x:Bind ViewModel.BannerViewModel.FileSystemInfoBar.IsOpen, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
+
+
+
DataContext.TryCast();
@@ -69,14 +71,34 @@ private async Task UpdateAdapterStatus(CancellationToken cancellationToken = def
if (fileSystem is null)
return;
+ // The Apply action only accompanies the WebDav file size limit suggestion
+ IsWebDavApplyVisible = false;
+
switch (fileSystem.Id)
{
case Core.WebDav.Constants.FileSystem.FS_ID:
{
+#if WINDOWS
+ // The WebDAV redirector rejects transfers of files larger than the configured
+ // allocation limit (~47MB by default), so suggest raising it to the maximum (4GB)
+ var fileSizeLimit = Helpers.WebDavRedirectorHelpers.GetFileSizeLimit();
+ if (fileSizeLimit >= Helpers.WebDavRedirectorHelpers.MAX_FILE_SIZE_LIMIT)
+ {
+ ViewModel.BannerViewModel.FileSystemInfoBar.IsOpen = false;
+ break;
+ }
+
+ IsWebDavApplyVisible = true;
+ ViewModel.BannerViewModel.FileSystemInfoBar.IsOpen = true;
+ ViewModel.BannerViewModel.FileSystemInfoBar.IsCloseable = false;
+ ViewModel.BannerViewModel.FileSystemInfoBar.Severity = Severity.Default;
+ ViewModel.BannerViewModel.FileSystemInfoBar.Message = "Windows limits the size of files transferred over WebDav. Increase the allocation size to the maximum limit (4GB) to copy larger files.";
+#else
ViewModel.BannerViewModel.FileSystemInfoBar.IsOpen = true;
ViewModel.BannerViewModel.FileSystemInfoBar.IsCloseable = false;
ViewModel.BannerViewModel.FileSystemInfoBar.Severity = Severity.Warning;
ViewModel.BannerViewModel.FileSystemInfoBar.Message = "WebDav is experimental. You may encounter bugs and stability issues. We recommend backing up your data before using WebDav.";
+#endif
break;
}
@@ -121,6 +143,25 @@ private async Task UpdateAdapterStatus(CancellationToken cancellationToken = def
}
}
+ private async void WebDavApply_Click(object sender, RoutedEventArgs e)
+ {
+ if (ViewModel is null || sender is not Button button)
+ return;
+
+ // Prevent double activation while the elevation prompt is shown
+ button.IsEnabled = false;
+
+ var applied = await Helpers.WebDavRedirectorHelpers.TrySetMaxFileSizeLimitAsync();
+ button.IsEnabled = true;
+
+ if (!applied)
+ return;
+
+ IsWebDavApplyVisible = false;
+ ViewModel.BannerViewModel.FileSystemInfoBar.Severity = Severity.Success;
+ ViewModel.BannerViewModel.FileSystemInfoBar.Message = "The maximum allocation size has been applied. The change will take effect after the WebClient service is restarted or Windows is rebooted.";
+ }
+
private void Root_Loaded(object sender, RoutedEventArgs e)
{
_ = AddTransitionsAsync();
From 7c0d5598776250728534a0b3c305d4b72304ac8b Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 00:22:22 +0200
Subject: [PATCH 02/55] Update nwebdav
---
lib/nwebdav | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/nwebdav b/lib/nwebdav
index e1df3f4a1..1577da3af 160000
--- a/lib/nwebdav
+++ b/lib/nwebdav
@@ -1 +1 @@
-Subproject commit e1df3f4a1c80aef525814c0c193ed56251224be2
+Subproject commit 1577da3af639e1b00fef2f1276ed2feec5e522f4
From cdc110ccebe903afb50b9c80824a2d6ff493bbac Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 01:12:51 +0200
Subject: [PATCH 03/55] Fixed CryptoFolder Id resolving in RenameAsync
---
.../AppModels/DokanyOptions.cs | 2 +-
.../SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs | 4 ++--
.../Buffers/HeaderBuffer.cs | 9 +++++++++
.../Storage/CryptoFolder.cs | 2 +-
.../Storage/CryptoStorable.cs | 4 ++--
.../Storage/StorageProperties/CryptoSizeOfProperty.cs | 4 +++-
.../AppModels/WebDavOptions.cs | 2 +-
.../AppModels/WinFspOptions.cs | 2 +-
8 files changed, 20 insertions(+), 9 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs b/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs
index c660e54b9..07db21040 100644
--- a/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs
+++ b/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs
@@ -33,7 +33,7 @@ public static DokanyOptions ToOptions(IDictionary options)
FileSystemStatistics = (IFileSystemStatistics?)options.Get(nameof(FileSystemStatistics)) ?? new FileSystemStatistics(),
IsReadOnly = (bool?)options.Get(nameof(IsReadOnly)) ?? false,
IsCachingChunks = (bool?)options.Get(nameof(IsCachingChunks)) ?? true,
- IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? true,
+ IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? false,
IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true,
RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L,
diff --git a/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs b/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs
index 0b1a6e204..f6ee1ace3 100644
--- a/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs
+++ b/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs
@@ -58,7 +58,7 @@ public static FuseOptions ToOptions(IDictionary options)
FileSystemStatistics = (IFileSystemStatistics?)options.Get(nameof(FileSystemStatistics)) ?? new FileSystemStatistics(),
IsReadOnly = (bool?)options.Get(nameof(IsReadOnly)) ?? false,
IsCachingChunks = (bool?)options.Get(nameof(IsCachingChunks)) ?? true,
- IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? true,
+ IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? false,
IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true,
RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L,
@@ -66,7 +66,7 @@ public static FuseOptions ToOptions(IDictionary options)
MountPoint = (string?)options.Get(nameof(MountPoint)),
AllowRootUserAccess = (bool?)options.Get(nameof(AllowRootUserAccess)) ?? false,
AllowOtherUserAccess = (bool?)options.Get(nameof(AllowOtherUserAccess)) ?? false,
- PrintDebugInformation = (bool?)options.Get(nameof(AllowOtherUserAccess)) ?? false
+ PrintDebugInformation = (bool?)options.Get(nameof(PrintDebugInformation)) ?? false
};
}
}
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs b/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs
index 2df82eea3..8e5b32627 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs
@@ -10,6 +10,15 @@ public sealed class HeaderBuffer : BufferHolder
///
public bool IsHeaderReady { get; set; }
+ ///
+ /// Gets the synchronization root that guards header initialization.
+ ///
+ ///
+ /// The header buffer is shared by all streams opened on the same file,
+ /// so reading or creating the header must be synchronized across streams.
+ ///
+ public object SyncRoot { get; } = new();
+
public HeaderBuffer(byte[] buffer)
: base(buffer)
{
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs
index a284f7255..f61894f32 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs
@@ -54,7 +54,7 @@ public virtual async Task RenameAsync(IStorableChild storable, s
var newCiphertextName = await AbstractPathHelpers.EncryptNameAsync(newName, Inner, specifics, cancellationToken);
var renamedCiphertextItem = await renamableFolder.RenameAsync(ciphertextItem, newCiphertextName, cancellationToken);
- var plaintextId = Path.Combine(Inner.Id, newName);
+ var plaintextId = Path.Combine(Id, newName);
return renamedCiphertextItem switch
{
IFile file => new CryptoFile(plaintextId, file, specifics, this),
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs
index eb645ab0f..e3dbe9620 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs
@@ -44,8 +44,8 @@ protected CryptoStorable(string plaintextId, TCapability inner, FileSystemSpecif
if (Inner is not IStorableChild storableChild)
throw new NotSupportedException("Retrieving the parent folder is not supported.");
- // Make sure we don't go outside the root
- if (storableChild.Id == specifics.ContentFolder.Id || !specifics.ContentFolder.Id.Contains(storableChild.Id))
+ // Make sure we don't go outside the root (the item must be located inside the content folder)
+ if (storableChild.Id == specifics.ContentFolder.Id || !storableChild.Id.StartsWith(specifics.ContentFolder.Id, StringComparison.Ordinal))
return null;
var ciphertextParent = await storableChild.GetParentAsync(cancellationToken);
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoSizeOfProperty.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoSizeOfProperty.cs
index 76c34f7e9..ff5184d85 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoSizeOfProperty.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoSizeOfProperty.cs
@@ -1,3 +1,4 @@
+using System;
using System.Threading;
using System.Threading.Tasks;
using SecureFolderFS.Storage.StorageProperties;
@@ -34,7 +35,8 @@ public CryptoSizeOfProperty(string id, FileSystemSpecifics specifics, ISizeOfPro
if (ciphertextSize is null)
return null;
- return _specifics.Security.ContentCrypt.CalculatePlaintextSize(ciphertextSize.Value - _specifics.Security.HeaderCrypt.HeaderCiphertextSize);
+ // Clamp to zero in case a new file may not have its header written yet
+ return _specifics.Security.ContentCrypt.CalculatePlaintextSize(Math.Max(0L, ciphertextSize.Value - _specifics.Security.HeaderCrypt.HeaderCiphertextSize));
}
}
}
\ No newline at end of file
diff --git a/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs b/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs
index 2dbef30de..fccac09d4 100644
--- a/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs
+++ b/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs
@@ -46,7 +46,7 @@ public static WebDavOptions ToOptions(IDictionary options)
FileSystemStatistics = (IFileSystemStatistics?)options.Get(nameof(FileSystemStatistics)) ?? new FileSystemStatistics(),
IsReadOnly = (bool?)options.Get(nameof(IsReadOnly)) ?? false,
IsCachingChunks = (bool?)options.Get(nameof(IsCachingChunks)) ?? true,
- IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? true,
+ IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? false,
IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true,
RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L,
diff --git a/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs b/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs
index fa8a1a22e..a7e39bfb4 100644
--- a/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs
+++ b/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs
@@ -33,7 +33,7 @@ public static WinFspOptions ToOptions(IDictionary options)
FileSystemStatistics = (IFileSystemStatistics?)options.Get(nameof(FileSystemStatistics)) ?? new FileSystemStatistics(),
IsReadOnly = (bool?)options.Get(nameof(IsReadOnly)) ?? false,
IsCachingChunks = (bool?)options.Get(nameof(IsCachingChunks)) ?? true,
- IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? true,
+ IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? false,
IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true,
RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L,
From 55b6fc2a8191f1aa90f252408ec691710ed3f3a8 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 01:46:39 +0200
Subject: [PATCH 04/55] Fixed ChunkWriter skipping farther chunks than written
---
.../Chunks/ChunkWriter.cs | 10 ++++++----
.../FileNames/NameWithDirectoryId.cs | 13 ++++++++++---
2 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs
index d272446e1..785b42fa5 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs
@@ -56,13 +56,15 @@ public void WriteChunk(long chunkNumber, ReadOnlySpan plaintextChunk)
_fileSystemStatistics.BytesEncrypted?.Report(plaintextChunk.Length);
- // Check position bounds
- if (streamPosition > _ciphertextStream.Length)
- return;
+ // Extend the stream when the chunk starts beyond the current end.
+ // The zero-filled region decrypts as valid zero chunks, so out-of-order
+ // chunk writes must not be dropped as that would silently lose data
+ if (_ciphertextStream.CanSeek && streamPosition > _ciphertextStream.Length)
+ _ciphertextStream.SetLength(streamPosition);
// Set the correct stream position
if (!_ciphertextStream.TrySetPositionOrAdvance(streamPosition))
- return;
+ throw new IOException($"The stream position could not be set to the chunk at {streamPosition}.");
// Write to stream at the correct chunk
_ciphertextStream.Write(realCiphertextChunk);
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/FileNames/NameWithDirectoryId.cs b/src/Core/SecureFolderFS.Core.FileSystem/FileNames/NameWithDirectoryId.cs
index 3450ed992..7617c0037 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/FileNames/NameWithDirectoryId.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/FileNames/NameWithDirectoryId.cs
@@ -20,7 +20,14 @@ public bool Equals(NameWithDirectoryId? other)
if (other is null)
return false;
- return DirectoryId.AsSpan() == other.DirectoryId.AsSpan() && FileName == other.FileName;
+ // Compare the Directory ID contents (the span equality operator only compares references)
+ return DirectoryId.AsSpan().SequenceEqual(other.DirectoryId.AsSpan()) && FileName == other.FileName;
+ }
+
+ ///
+ public override bool Equals(object? obj)
+ {
+ return Equals(obj as NameWithDirectoryId);
}
///
@@ -29,8 +36,8 @@ public override int GetHashCode()
unchecked
{
var hash = 17;
- hash *= 23 + FileName.GetHashCode();
- hash *= 23 + ComputeHash(DirectoryId);
+ hash = hash * 23 + FileName.GetHashCode();
+ hash = hash * 23 + ComputeHash(DirectoryId);
return hash;
}
From 9d8ff10cb439f026bcc138c92b04a3fa7960659d Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 01:51:38 +0200
Subject: [PATCH 05/55] Fixed UniversalCache cache eviction
---
.../SecureFolderFS.Core.FileSystem/UniversalCache.cs | 10 ++++++++++
.../VirtualFileSystem/VirtualFileSystemOptions.cs | 8 ++++++--
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs b/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs
index 0b60b3d43..c93e37f60 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs
@@ -10,6 +10,7 @@ public class UniversalCache : IDisposable
protected readonly Dictionary cache;
protected readonly IProgress? cacheStatistics;
protected readonly object threadLock = new();
+ protected readonly int capacity;
///
/// Determines whether caching is enabled.
@@ -25,6 +26,7 @@ public UniversalCache(int capacity, IProgress? cacheStatistics)
{
this.cache = capacity < 0 ? new() : new(capacity);
this.cacheStatistics = cacheStatistics;
+ this.capacity = capacity;
IsAvailable = capacity < 0 || capacity > 0;
}
@@ -65,6 +67,14 @@ public virtual bool CacheSet(TKey key, TValue value, bool skipExistingCheck = fa
lock (threadLock)
{
+ // Evict the oldest entry when the capacity is reached
+ if (capacity > 0 && cache.Count >= capacity && !cache.ContainsKey(key))
+ {
+ using var enumerator = cache.Keys.GetEnumerator();
+ if (enumerator.MoveNext())
+ cache.Remove(enumerator.Current);
+ }
+
if (!skipExistingCheck)
return cache.TryAdd(key, value);
diff --git a/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs
index 8dd52a9fe..a5d9e0d94 100644
--- a/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs
+++ b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs
@@ -35,7 +35,11 @@ public class VirtualFileSystemOptions : FileSystemOptions
///
/// Gets or sets whether to enable caching for ciphertext and plaintext names.
///
- public bool IsCachingFileNames { get; protected set => SetField(ref field, value); } = true;
+ ///
+ /// File name caching is disabled by default. Enabling it requires cache invalidation
+ /// for renames, moves, and deletions across all file system layers.
+ ///
+ public bool IsCachingFileNames { get; protected set => SetField(ref field, value); } = false;
///
/// Sets the read-only status of the file system.
@@ -83,7 +87,7 @@ public static VirtualFileSystemOptions ToOptions(
FileSystemStatistics = GetOption(options, nameof(FileSystemStatistics)) ?? fileSystemStatistics.Invoke(),
IsReadOnly = GetOption(options, nameof(IsReadOnly)) ?? false,
IsCachingChunks = GetOption(options, nameof(IsCachingChunks)) ?? true,
- IsCachingFileNames = GetOption(options, nameof(IsCachingFileNames)) ?? true,
+ IsCachingFileNames = GetOption(options, nameof(IsCachingFileNames)) ?? false,
IsCachingDirectoryIds = GetOption(options, nameof(IsCachingDirectoryIds)) ?? true,
RecycleBinSize = GetOption(options, nameof(RecycleBinSize)) ?? 0L
};
From ca5e2e20e3d7982a39c9cd80c606877c8f3760e7 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 02:07:56 +0200
Subject: [PATCH 06/55] Use thread locks in ChunkAccess
---
.../Chunks/CachingChunkAccess.cs | 135 ++++++++++------
.../Chunks/ChunkAccess.cs | 146 ++++++++++--------
2 files changed, 172 insertions(+), 109 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs
index bd4a8a27d..b0e37feec 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs
@@ -18,8 +18,18 @@ public override bool FlushAvailable
{
get
{
+ // Hold the cache lock for the entire operation
lock (_chunkCache)
- return _chunkCache.Count > 0;
+ {
+ // Only chunks that were actually modified need flushing
+ foreach (var item in _chunkCache)
+ {
+ if (item.Value.WasModified)
+ return true;
+ }
+
+ return false;
+ }
}
}
@@ -32,90 +42,109 @@ public CachingChunkAccess(ChunkReader chunkReader, ChunkWriter chunkWriter, ICon
///
public override int CopyFromChunk(long chunkNumber, Span destination, int offsetInChunk)
{
- // Get chunk
- var plaintextChunk = GetChunk(chunkNumber);
- if (plaintextChunk is null)
- return -1;
+ // Hold the cache lock for the entire operation
+ lock (_chunkCache)
+ {
+ // Get chunk
+ var plaintextChunk = GetChunk(chunkNumber);
+ if (plaintextChunk is null)
+ return -1;
- // Copy from chunk
- var count = Math.Min(plaintextChunk.ActualLength - offsetInChunk, destination.Length);
- if (count < 0)
- return -1;
+ // Copy from chunk
+ var count = Math.Min(plaintextChunk.ActualLength - offsetInChunk, destination.Length);
+ if (count < 0)
+ return -1;
- plaintextChunk.Buffer.AsSpan(offsetInChunk, count).CopyTo(destination);
+ plaintextChunk.Buffer.AsSpan(offsetInChunk, count).CopyTo(destination);
- return count;
+ return count;
+ }
}
///
public override int CopyToChunk(long chunkNumber, ReadOnlySpan source, int offsetInChunk)
{
- // Get chunk
- var plaintextChunk = GetChunk(chunkNumber);
- if (plaintextChunk is null)
- return -1;
+ // Hold the cache lock for the entire operation
+ lock (_chunkCache)
+ {
+ // Get chunk
+ var plaintextChunk = GetChunk(chunkNumber);
+ if (plaintextChunk is null)
+ return -1;
- // Update state of chunk
- plaintextChunk.WasModified = true;
+ // Update state of chunk
+ plaintextChunk.WasModified = true;
- // Copy to chunk
- var count = Math.Min(contentCrypt.ChunkPlaintextSize - offsetInChunk, source.Length);
- if (count < 0)
- return -1;
+ // Copy to chunk
+ var count = Math.Min(contentCrypt.ChunkPlaintextSize - offsetInChunk, source.Length);
+ if (count < 0)
+ return -1;
- var destination = plaintextChunk.Buffer.AsSpan(offsetInChunk, count);
- source.Slice(0, count).CopyTo(destination);
+ var destination = plaintextChunk.Buffer.AsSpan(offsetInChunk, count);
+ source.Slice(0, count).CopyTo(destination);
- // Update actual length
- plaintextChunk.ActualLength = Math.Max(plaintextChunk.ActualLength, count + offsetInChunk);
+ // Update actual length
+ plaintextChunk.ActualLength = Math.Max(plaintextChunk.ActualLength, count + offsetInChunk);
- return count;
+ return count;
+ }
}
///
public override void SetChunkLength(long chunkNumber, int length, bool includeCurrentLength = false)
{
- // Get chunk
- var plaintextChunk = GetChunk(chunkNumber);
- if (plaintextChunk is null)
- return;
+ // Hold the cache lock for the entire operation
+ lock (_chunkCache)
+ {
+ // Get chunk
+ var plaintextChunk = GetChunk(chunkNumber);
+ if (plaintextChunk is null)
+ return;
- // Add read length of existing chunk data to the full length if specified
- length += includeCurrentLength ? plaintextChunk.ActualLength : 0;
- length = Math.Max(length, 0);
+ // Add read length of existing chunk data to the full length if specified
+ length += includeCurrentLength ? plaintextChunk.ActualLength : 0;
+ length = Math.Max(length, 0);
- // Determine whether to extend or truncate the chunk
- if (length < plaintextChunk.ActualLength)
- {
- // Truncate chunk
- plaintextChunk.ActualLength = Math.Min(plaintextChunk.ActualLength, length);
- }
- else if (plaintextChunk.ActualLength < length)
- {
- // Extend chunk
- plaintextChunk.ActualLength = Math.Min(length, contentCrypt.ChunkPlaintextSize);
- }
- else
- return; // Ignore resizing the same length
+ // Determine whether to extend or truncate the chunk
+ if (length < plaintextChunk.ActualLength)
+ {
+ // Truncate chunk
+ plaintextChunk.ActualLength = Math.Min(plaintextChunk.ActualLength, length);
+ }
+ else if (plaintextChunk.ActualLength < length)
+ {
+ // Extend chunk
+ plaintextChunk.ActualLength = Math.Min(length, contentCrypt.ChunkPlaintextSize);
+ }
+ else
+ return; // Ignore resizing the same length
- plaintextChunk.WasModified = true;
+ plaintextChunk.WasModified = true;
+ }
}
///
public override void Flush()
{
+ // Hold the cache lock for the entire operation
lock (_chunkCache)
{
foreach (var item in _chunkCache)
{
if (item.Value.WasModified)
+ {
chunkWriter.WriteChunk(item.Key, item.Value.Buffer.AsSpan(0, item.Value.ActualLength));
+
+ // Mark the chunk as clean so subsequent flushes don't rewrite it
+ item.Value.WasModified = false;
+ }
}
}
}
private ChunkBuffer? GetChunk(long chunkNumber)
{
+ // Hold the cache lock for the entire operation
lock (_chunkCache)
{
if (!_chunkCache.TryGetValue(chunkNumber, out var plaintextChunk))
@@ -147,6 +176,7 @@ public override void Flush()
private void SetChunk(long chunkNumber, ChunkBuffer plaintextChunk)
{
+ // Hold the cache lock for the entire operation
lock (_chunkCache)
{
if (_chunkCache.Count >= FileSystem.Constants.Caching.RECOMMENDED_SIZE_CHUNKS)
@@ -171,6 +201,17 @@ public override void Dispose()
{
lock (_chunkCache)
{
+ try
+ {
+ // Flush outstanding modified chunks so data is not lost when
+ // the chunk access is disposed without a prior flush
+ Flush();
+ }
+ catch (Exception)
+ {
+ // Dispose must not throw (the backing stream may already be unavailable)
+ }
+
base.Dispose();
_chunkCache.Clear();
}
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs
index f82c17561..faa37ce8d 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs
@@ -16,6 +16,16 @@ internal class ChunkAccess : IDisposable
protected readonly IContentCrypt contentCrypt;
protected readonly IFileSystemStatistics fileSystemStatistics;
+ ///
+ /// The synchronization root guarding chunk operations.
+ ///
+ ///
+ /// A chunk access instance can be shared by multiple streams of the same file,
+ /// and the reader/writer also share the position of one ciphertext stream,
+ /// so chunk operations must not interleave.
+ ///
+ protected readonly object chunkLock = new();
+
///
/// Determines whether there are outstanding chunks ready to be flushed to disk.
///
@@ -42,24 +52,28 @@ public virtual int CopyFromChunk(long chunkNumber, Span destination, int o
var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize);
try
{
- // ArrayPool may return a larger array than requested
- var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize);
+ // Hold the cache lock for the entire operation
+ lock (chunkLock)
+ {
+ // ArrayPool may return a larger array than requested
+ var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize);
- // Read chunk
- var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk);
+ // Read chunk
+ var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk);
- // Check for any errors
- if (read < 0)
- return read;
+ // Check for any errors
+ if (read < 0)
+ return read;
- // Copy from chunk
- var count = Math.Min(read - offsetInChunk, destination.Length);
- if (count <= 0)
- return 0;
+ // Copy from chunk
+ var count = Math.Min(read - offsetInChunk, destination.Length);
+ if (count <= 0)
+ return 0;
- realPlaintextChunk.Slice(offsetInChunk, count).CopyTo(destination);
+ realPlaintextChunk.Slice(offsetInChunk, count).CopyTo(destination);
- return count;
+ return count;
+ }
}
finally
{
@@ -84,28 +98,32 @@ public virtual int CopyToChunk(long chunkNumber, ReadOnlySpan source, int
var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize);
try
{
- // ArrayPool may return larger array than requested
- var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize);
+ // Hold the cache lock for the entire operation
+ lock (chunkLock)
+ {
+ // ArrayPool may return larger array than requested
+ var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize);
- // Read chunk
- var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk);
+ // Read chunk
+ var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk);
- // Check for any errors
- if (read < 0)
- return read;
+ // Check for any errors
+ if (read < 0)
+ return read;
- // Copy to chunk
- var count = Math.Min(contentCrypt.ChunkPlaintextSize - offsetInChunk, source.Length);
- if (count <= 0)
- return 0;
+ // Copy to chunk
+ var count = Math.Min(contentCrypt.ChunkPlaintextSize - offsetInChunk, source.Length);
+ if (count <= 0)
+ return 0;
- var destination = realPlaintextChunk.Slice(offsetInChunk, count);
- source.Slice(0, count).CopyTo(destination);
+ var destination = realPlaintextChunk.Slice(offsetInChunk, count);
+ source.Slice(0, count).CopyTo(destination);
- // Write to chunk
- chunkWriter.WriteChunk(chunkNumber, destination);
+ // Write to chunk
+ chunkWriter.WriteChunk(chunkNumber, destination);
- return count;
+ return count;
+ }
}
finally
{
@@ -129,41 +147,45 @@ public virtual void SetChunkLength(long chunkNumber, int length, bool includeCur
var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize);
try
{
- // ArrayPool may return larger array than requested
- var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize);
-
- // Read chunk
- var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk);
-
- // Check for any errors
- if (read < 0)
- throw new CryptographicException();
-
- // Add read length of existing chunk data to the full length if specified
- length += includeCurrentLength ? read : 0;
- length = Math.Max(length, 0);
-
- Span newPlaintextChunk;
-
- // Determine whether to extend or truncate the chunk
- if (length < read)
+ // Hold the cache lock for the entire operation
+ lock (chunkLock)
{
- // Truncate chunk
- newPlaintextChunk = realPlaintextChunk.Slice(0, Math.Min(read, length));
+ // ArrayPool may return larger array than requested
+ var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize);
+
+ // Read chunk
+ var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk);
+
+ // Check for any errors
+ if (read < 0)
+ throw new CryptographicException();
+
+ // Add read length of existing chunk data to the full length if specified
+ length += includeCurrentLength ? read : 0;
+ length = Math.Max(length, 0);
+
+ Span newPlaintextChunk;
+
+ // Determine whether to extend or truncate the chunk
+ if (length < read)
+ {
+ // Truncate chunk
+ newPlaintextChunk = realPlaintextChunk.Slice(0, Math.Min(read, length));
+ }
+ else if (read < length)
+ {
+ // Clear residual data from ArrayPool and append zeros
+ realPlaintextChunk.Slice(read).Clear();
+
+ // Extend chunk
+ newPlaintextChunk = realPlaintextChunk.Slice(0, Math.Min(length, contentCrypt.ChunkPlaintextSize));
+ }
+ else
+ return; // Ignore resizing the same length
+
+ // Save newly modified chunk
+ chunkWriter.WriteChunk(chunkNumber, newPlaintextChunk);
}
- else if (read < length)
- {
- // Clear residual data from ArrayPool and append zeros
- realPlaintextChunk.Slice(read).Clear();
-
- // Extend chunk
- newPlaintextChunk = realPlaintextChunk.Slice(0, Math.Min(length, contentCrypt.ChunkPlaintextSize));
- }
- else
- return; // Ignore resizing the same length
-
- // Save newly modified chunk
- chunkWriter.WriteChunk(chunkNumber, newPlaintextChunk);
}
finally
{
From 6f8ab0c695b333e4a75faf2a9279e4645ec03e13 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 02:16:08 +0200
Subject: [PATCH 07/55] Refresh FileInfo state when computing in
WinFspFileHandle
---
.../OpenHandles/WinFspFileHandle.cs | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.WinFsp/OpenHandles/WinFspFileHandle.cs b/src/Core/SecureFolderFS.Core.WinFsp/OpenHandles/WinFspFileHandle.cs
index de015446a..2c595b077 100644
--- a/src/Core/SecureFolderFS.Core.WinFsp/OpenHandles/WinFspFileHandle.cs
+++ b/src/Core/SecureFolderFS.Core.WinFsp/OpenHandles/WinFspFileHandle.cs
@@ -1,5 +1,6 @@
using SecureFolderFS.Core.Cryptography;
using SecureFolderFS.Core.FileSystem.OpenHandles;
+using SecureFolderFS.Shared.Helpers;
using System;
using System.IO;
using FileInfo = Fsp.Interop.FileInfo;
@@ -22,12 +23,19 @@ public WinFspFileHandle(System.IO.FileInfo fileInfo, Security security, Stream s
public FileInfo GetFileInfo()
{
- return ToFileInfo(FileInfo, _security);
+ // System.IO.FileInfo caches its state on first access so refresh is needed to avoid returning
+ // stale metadata after the file was modified through this (or another) handle
+ FileInfo.Refresh();
+
+ // The plaintext stream tracks the up-to-date length including data that
+ // has not been flushed to the ciphertext file yet
+ var plaintextSize = SafetyHelpers.NoFailureResult(() => Stream.Length);
+ return ToFileInfo(FileInfo, _security, plaintextSize);
}
- public static FileInfo ToFileInfo(System.IO.FileInfo fileInfo, Security security)
+ public static FileInfo ToFileInfo(System.IO.FileInfo fileInfo, Security security, long? plaintextSize = null)
{
- var size = (ulong)security.ContentCrypt.CalculatePlaintextSize(Math.Max(0L, fileInfo.Length - security.HeaderCrypt.HeaderCiphertextSize));
+ var size = (ulong)(plaintextSize ?? security.ContentCrypt.CalculatePlaintextSize(Math.Max(0L, fileInfo.Length - security.HeaderCrypt.HeaderCiphertextSize)));
return new()
{
FileAttributes = (uint)(fileInfo.Attributes == 0 ? FileAttributes.Normal : fileInfo.Attributes),
From 3f9f6f26b80cacfaf4198bc46aba3a5cdaa20c7f Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 02:35:04 +0200
Subject: [PATCH 08/55] Added atomic GetOrCreate to OpenCryptFileManager
---
.../CryptFiles/OpenCryptFile.cs | 74 ++++++++++++-------
.../CryptFiles/OpenCryptFileManager.cs | 24 ++++++
.../Streams/StreamsAccess.cs | 4 +-
3 files changed, 74 insertions(+), 28 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs
index 1fb2ad119..4565b6d12 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs
@@ -18,12 +18,18 @@ internal sealed class OpenCryptFile : IDisposable
private readonly Action _notifyClosed;
private readonly OpenCryptFileManager _cryptFileManager;
private readonly Dictionary _openedStreams;
+ private readonly object _streamsLock = new();
///
/// Gets the unique ID of the file.
///
public string Id { get; }
+ ///
+ /// Gets the value that determines whether this instance has been disposed of.
+ ///
+ public bool IsDisposed { get; private set; }
+
///
/// Gets the buffer that holds the header information for the cryptographic file.
///
@@ -51,50 +57,66 @@ public OpenCryptFile(
/// A new instance of .
public PlaintextStream OpenStream(Stream ciphertextStream)
{
- if (_openedStreams.TryGetValue(ciphertextStream, out var existing))
- _openedStreams[ciphertextStream] = (existing.RefCount + 1, existing.ChunkAccess);
- else
+ lock (_streamsLock)
{
- var chunkAccess = _cryptFileManager.CreateChunkAccess(ciphertextStream, HeaderBuffer);
- _openedStreams.Add(ciphertextStream, (1, chunkAccess));
- }
+ if (IsDisposed)
+ ObjectDisposedException.ThrowIf(IsDisposed, this);
- return new PlaintextStream(ciphertextStream, _security, _openedStreams[ciphertextStream].ChunkAccess, HeaderBuffer, NotifyClosed);
+ if (_openedStreams.TryGetValue(ciphertextStream, out var existing))
+ _openedStreams[ciphertextStream] = (existing.RefCount + 1, existing.ChunkAccess);
+ else
+ {
+ var chunkAccess = _cryptFileManager.CreateChunkAccess(ciphertextStream, HeaderBuffer);
+ _openedStreams.Add(ciphertextStream, (1, chunkAccess));
+ }
+
+ return new PlaintextStream(ciphertextStream, _security, _openedStreams[ciphertextStream].ChunkAccess, HeaderBuffer, NotifyClosed);
+ }
}
private void NotifyClosed(Stream ciphertextStream)
{
- if (_openedStreams.TryGetValue(ciphertextStream, out var existing))
+ lock (_streamsLock)
{
- // Make sure to remove it and update the reference count
- if (--existing.RefCount <= 0)
+ if (_openedStreams.TryGetValue(ciphertextStream, out var existing))
{
- // Dispose of the stream
- _openedStreams.Remove(ciphertextStream);
- existing.ChunkAccess.Dispose();
- ciphertextStream.Dispose();
+ // Make sure to remove it and update the reference count
+ if (--existing.RefCount <= 0)
+ {
+ // Dispose of the stream
+ _openedStreams.Remove(ciphertextStream);
+ existing.ChunkAccess.Dispose();
+ ciphertextStream.Dispose();
+ }
+ else
+ _openedStreams[ciphertextStream] = (existing.RefCount, existing.ChunkAccess);
}
- else
- _openedStreams[ciphertextStream] = (existing.RefCount, existing.ChunkAccess);
- }
- // Notify closed if no streams left
- if (_openedStreams.IsEmpty())
- {
- _notifyClosed(Id);
- Dispose();
+ // Notify closed if no streams left
+ if (_openedStreams.IsEmpty())
+ {
+ _notifyClosed(Id);
+ Dispose();
+ }
}
}
///
public void Dispose()
{
- foreach (var (stream, (_, chunkAccess)) in _openedStreams)
+ lock (_streamsLock)
{
- chunkAccess.Dispose();
- stream.Dispose();
+ if (IsDisposed)
+ return;
+
+ IsDisposed = true;
+ foreach (var (stream, (_, chunkAccess)) in _openedStreams)
+ {
+ chunkAccess.Dispose();
+ stream.Dispose();
+ }
+ _openedStreams.Clear();
}
- _openedStreams.Clear();
}
}
}
\ No newline at end of file
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs
index 182241a0a..05ee2a388 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs
@@ -57,6 +57,30 @@ public OpenCryptFile NewCryptFile(string id, BufferHolder headerBuffer)
return cryptFile;
}
+ ///
+ /// Atomically gets an existing or creates a new one.
+ ///
+ /// The unique ID of the file.
+ /// The factory invoked to create the plaintext header buffer for a new file.
+ /// An instance of .
+ ///
+ /// Getting and creating must happen under one lock, otherwise two threads opening the same
+ /// file simultaneously would create two instances with independent header buffers.
+ ///
+ public OpenCryptFile GetOrCreate(string id, Func headerBufferFactory)
+ {
+ lock (_openCryptFiles)
+ {
+ if (_openCryptFiles.TryGetValue(id, out var existing) && !existing.IsDisposed)
+ return existing;
+
+ var cryptFile = new OpenCryptFile(id, _security, headerBufferFactory.Invoke(_security), this, NotifyClosed);
+ _openCryptFiles[id] = cryptFile;
+
+ return cryptFile;
+ }
+ }
+
///
/// Creates a new bound exclusively to .
///
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Streams/StreamsAccess.cs b/src/Core/SecureFolderFS.Core.FileSystem/Streams/StreamsAccess.cs
index 0ff268594..54660a146 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Streams/StreamsAccess.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Streams/StreamsAccess.cs
@@ -31,7 +31,7 @@ public Stream OpenPlaintextStream(string id, Stream wrappedStream, bool takeFail
try
{
// Get or create the encrypted file from the file system
- var openCryptFile = _cryptFileManager.TryGet(id) ?? _cryptFileManager.NewCryptFile(id, new HeaderBuffer(_security.HeaderCrypt.HeaderPlaintextSize));
+ var openCryptFile = _cryptFileManager.GetOrCreate(id, static security => new HeaderBuffer(security.HeaderCrypt.HeaderPlaintextSize));
// Open a new stream for that file registering the existing ciphertext stream
return openCryptFile.OpenStream(wrappedStream);
@@ -62,7 +62,7 @@ public Stream OpenPlaintextStream(string id, Stream wrappedStream, Stream? heade
try
{
// Get or create the encrypted file from the file system
- var openCryptFile = _cryptFileManager.TryGet(id) ?? _cryptFileManager.NewCryptFile(id, new HeaderBuffer(_security.HeaderCrypt.HeaderPlaintextSize));
+ var openCryptFile = _cryptFileManager.GetOrCreate(id, static security => new HeaderBuffer(security.HeaderCrypt.HeaderPlaintextSize));
// Check if the header is ready or if it can be read at a later time
var headerBuffer = openCryptFile.HeaderBuffer;
From 3b0ba3740812a25ebb3443e036a3d37eb727f10f Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 02:45:00 +0200
Subject: [PATCH 09/55] Minor fix for NativePathHelpers.MakeRelative
---
.../Helpers/Paths/Native/NativePathHelpers.cs | 10 ++++++++--
.../Helpers/Paths/PathHelpers.cs | 2 +-
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs
index 63805511c..463cc06fd 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs
@@ -1,4 +1,4 @@
-using System.IO;
+using System;
namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native
{
@@ -9,7 +9,13 @@ public static partial class NativePathHelpers
{
public static string MakeRelative(string fullPath, string basePath)
{
- return PathHelpers.EnsureNoLeadingPathSeparator(Path.DirectorySeparatorChar + fullPath.Replace(basePath, string.Empty));
+ // Only strip the base path when it is an actual prefix of the full path.
+ // Replacing all occurrences could corrupt paths that contain the base path elsewhere
+ var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ if (fullPath.StartsWith(basePath, comparison))
+ fullPath = fullPath.Substring(basePath.Length);
+
+ return PathHelpers.EnsureNoLeadingPathSeparator(fullPath);
}
}
}
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs
index 3e939fd5e..fbd66a0d3 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs
@@ -32,7 +32,7 @@ public static string EnsureNoLeadingPathSeparator(string path)
.Select(item => $"{item}:")
.FirstOrDefault();
}
- else if (OperatingSystem.IsMacCatalyst() || OperatingSystem.IsMacOS())
+ else if (OperatingSystem.IsMacOS())
{
return $"{Path.DirectorySeparatorChar}{Path.Combine("Volumes", nameHint)}{Path.DirectorySeparatorChar}";
}
From 26b68c6b06e4d55fb0786c04dfcdfa7d959debba Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 02:56:16 +0200
Subject: [PATCH 10/55] Drop MethodImplOptions.Synchronized from Dokany and use
locks instead
---
.../Callbacks/BaseDokanyCallbacks.cs | 102 +++++++++++-------
1 file changed, 64 insertions(+), 38 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs
index 98fbeef5f..9a3283132 100644
--- a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs
+++ b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs
@@ -1,10 +1,4 @@
-using DokanNet;
-using SecureFolderFS.Core.Dokany.Helpers;
-using SecureFolderFS.Core.FileSystem;
-using SecureFolderFS.Core.FileSystem.AppModels;
-using SecureFolderFS.Core.FileSystem.Exceptions;
-using SecureFolderFS.Core.FileSystem.OpenHandles;
-using System;
+using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@@ -12,6 +6,13 @@
using System.Runtime.CompilerServices;
using System.Security.AccessControl;
using System.Security.Cryptography;
+using DokanNet;
+using SecureFolderFS.Core.Dokany.Helpers;
+using SecureFolderFS.Core.FileSystem;
+using SecureFolderFS.Core.FileSystem.AppModels;
+using SecureFolderFS.Core.FileSystem.Exceptions;
+using SecureFolderFS.Core.FileSystem.Helpers;
+using SecureFolderFS.Core.FileSystem.OpenHandles;
using FileAccess = DokanNet.FileAccess;
namespace SecureFolderFS.Core.Dokany.Callbacks
@@ -64,12 +65,20 @@ public virtual NtStatus FlushFileBuffers(string fileName, IDokanFileInfo info)
try
{
- fileHandle.Stream.Flush();
+ lock (fileHandle.Stream)
+ fileHandle.Stream.Flush();
+
return Trace(DokanResult.Success, fileName, info);
}
- catch (IOException)
+ catch (IOException ioEx)
{
- return DokanResult.DiskFull;
+ if (ErrorHandlingHelpers.IsDiskFullException(ioEx))
+ return Trace(DokanResult.DiskFull, fileName, info);
+
+ if (DokanyErrorHelpers.NtStatusFromException(ioEx, out var ntStatus))
+ return Trace((NtStatus)ntStatus, fileName, info);
+
+ return Trace(DokanResult.Unsuccessful, fileName, info);
}
}
@@ -88,7 +97,9 @@ public virtual NtStatus SetEndOfFile(string fileName, long length, IDokanFileInf
if (handlesManager.GetHandle(GetContextValue(info)) is not { } fileHandle)
return Trace(DokanResult.InvalidHandle, fileName, info);
- fileHandle.Stream.SetLength(length);
+ lock (fileHandle.Stream)
+ fileHandle.Stream.SetLength(length);
+
return Trace(DokanResult.Success, fileName, info);
}
@@ -131,7 +142,6 @@ public virtual NtStatus FindStreams(string fileName, out IList
}
///
- [MethodImpl(MethodImplOptions.Synchronized)]
public virtual unsafe NtStatus ReadFile(string fileName, IntPtr buffer, uint bufferLength, out int bytesRead, long offset,
IDokanFileInfo info)
{
@@ -164,20 +174,25 @@ public virtual unsafe NtStatus ReadFile(string fileName, IntPtr buffer, uint buf
try
{
- // Check EOF
- if (offset >= fileHandle.Stream.Length)
+ // Lock on the handle's stream. The kernel can issue concurrent operations
+ // on the same handle, and setting the position and reading must be atomic
+ lock (fileHandle.Stream)
{
- bytesRead = 0;
- return NtStatus.EndOfFile;
+ // Check EOF
+ if (offset >= fileHandle.Stream.Length)
+ {
+ bytesRead = 0;
+ return NtStatus.EndOfFile;
+ }
+
+ // Align position
+ fileHandle.Stream.Position = offset;
+
+ // Read file
+ var bufferSpan = new Span(buffer.ToPointer(), (int)bufferLength);
+ bytesRead = fileHandle.Stream.Read(bufferSpan);
}
- // Align position
- fileHandle.Stream.Position = offset;
-
- // Read file
- var bufferSpan = new Span(buffer.ToPointer(), (int)bufferLength);
- bytesRead = fileHandle.Stream.Read(bufferSpan);
-
return Trace(DokanResult.Success, fileName, info);
}
catch (PathTooLongException)
@@ -203,7 +218,6 @@ public virtual unsafe NtStatus ReadFile(string fileName, IntPtr buffer, uint buf
}
///
- [MethodImpl(MethodImplOptions.Synchronized)]
public virtual unsafe NtStatus WriteFile(string fileName, IntPtr buffer, uint bufferLength, out int bytesWritten, long offset, IDokanFileInfo info)
{
if (specifics.Options.IsReadOnly)
@@ -228,7 +242,9 @@ public virtual unsafe NtStatus WriteFile(string fileName, IntPtr buffer, uint bu
if (handlesManager.GetHandle(GetContextValue(info)) is not { } fileHandle)
{
// Invalid handle...
- contextHandle = handlesManager.OpenFileHandle(ciphertextPath, appendToFile ? FileMode.Append : FileMode.Open, System.IO.FileAccess.ReadWrite, FileShare.Read, FileOptions.None);
+ // FileMode.Append cannot be combined with FileAccess.ReadWrite - open normally
+ // and seek to the end instead (the append position is aligned below)
+ contextHandle = handlesManager.OpenFileHandle(ciphertextPath, appendToFile ? FileMode.OpenOrCreate : FileMode.Open, System.IO.FileAccess.ReadWrite, FileShare.Read, FileOptions.None);
fileHandle = handlesManager.GetHandle(contextHandle);
openedNewHandle = true;
}
@@ -242,19 +258,24 @@ public virtual unsafe NtStatus WriteFile(string fileName, IntPtr buffer, uint bu
try
{
- // Align for Paging I/O
- var alignedBytesToCopy = AlignSizeForPagingIo((int)bufferLength, offset, fileHandle.Stream.Length, info);
+ // Lock on the handle's stream as the kernel can issue concurrent operations
+ // on the same handle, and setting the position and writing must be atomic
+ lock (fileHandle.Stream)
+ {
+ // Align for Paging I/O
+ var alignedBytesToCopy = AlignSizeForPagingIo((int)bufferLength, offset, fileHandle.Stream.Length, info);
- // Align position for offset
- var alignedPosition = appendToFile ? fileHandle.Stream.Length : offset;
+ // Align position for offset
+ var alignedPosition = appendToFile ? fileHandle.Stream.Length : offset;
- // Align position
- fileHandle.Stream.Position = alignedPosition;
+ // Align position
+ fileHandle.Stream.Position = alignedPosition;
- // Write file
- var bufferSpan = new ReadOnlySpan(buffer.ToPointer(), alignedBytesToCopy);
- fileHandle.Stream.Write(bufferSpan);
- bytesWritten = alignedBytesToCopy;
+ // Write file
+ var bufferSpan = new ReadOnlySpan(buffer.ToPointer(), alignedBytesToCopy);
+ fileHandle.Stream.Write(bufferSpan);
+ bytesWritten = alignedBytesToCopy;
+ }
return Trace(DokanResult.Success, fileName, info);
}
@@ -376,6 +397,11 @@ protected static int AlignSizeForPagingIo(int bufferLength, long offset, long st
return bufferLength;
var longDistanceToEnd = streamLength - offset;
+
+ // Paging I/O must not write beyond the end of the file
+ if (longDistanceToEnd <= 0L)
+ return 0;
+
if (longDistanceToEnd > int.MaxValue)
return bufferLength;
@@ -394,7 +420,7 @@ protected static NtStatus Trace(NtStatus result, string fileName, IDokanFileInfo
if (Debugger.IsAttached)
return result;
- if (!Core.FileSystem.Constants.OPT_IN_FOR_OPTIONAL_DEBUG_TRACING)
+ if (!FileSystem.Constants.OPT_IN_FOR_OPTIONAL_DEBUG_TRACING)
return result;
if (DisallowedTraceMethods.Contains(methodName))
@@ -413,7 +439,7 @@ protected static NtStatus Trace(NtStatus result, string? fileName, IDokanFileInf
return result;
#endif
- if (!Core.FileSystem.Constants.OPT_IN_FOR_OPTIONAL_DEBUG_TRACING)
+ if (!FileSystem.Constants.OPT_IN_FOR_OPTIONAL_DEBUG_TRACING)
return result;
if (!Debugger.IsAttached)
@@ -434,7 +460,7 @@ protected static NtStatus Trace(NtStatus result, string? fileName, IDokanFileInf
private static string[] DisallowedTraceMethods { get; } =
{
- "GetVolumeInformation"
+ nameof(GetVolumeInformation)
};
}
}
From 29da5617a7b015b2371be9094a430f893538948c Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 03:08:58 +0200
Subject: [PATCH 11/55] Use File.Replace instead of Delete + Move in Dokany
---
.../Callbacks/OnDeviceDokany.cs | 61 ++++++++++---------
1 file changed, 31 insertions(+), 30 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs
index 4d1308fc1..4a05e6716 100644
--- a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs
+++ b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs
@@ -1,4 +1,11 @@
-using DokanNet;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Security.AccessControl;
+using System.Security.Cryptography;
+using DokanNet;
using OwlCore.Storage;
using SecureFolderFS.Core.Dokany.Helpers;
using SecureFolderFS.Core.Dokany.OpenHandles;
@@ -13,15 +20,6 @@
using SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native;
using SecureFolderFS.Core.FileSystem.OpenHandles;
using SecureFolderFS.Storage.VirtualFileSystem;
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.IO;
-using System.Linq;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-using System.Security.AccessControl;
-using System.Security.Cryptography;
using FileAccess = DokanNet.FileAccess;
namespace SecureFolderFS.Core.Dokany.Callbacks
@@ -518,15 +516,26 @@ public override NtStatus DeleteDirectory(string fileName, IDokanFileInfo info)
if (ciphertextPath is null)
return Trace(NtStatus.ObjectPathInvalid, fileName, info);
- using var directoryEnumerator = Directory.EnumerateFileSystemEntries(ciphertextPath).GetEnumerator();
- while (directoryEnumerator.MoveNext())
+ try
{
- // Check for any files except core files
- canDelete &= PathHelpers.IsCoreName(Path.GetFileName(directoryEnumerator.Current));
+ using var directoryEnumerator = Directory.EnumerateFileSystemEntries(ciphertextPath).GetEnumerator();
+ while (directoryEnumerator.MoveNext())
+ {
+ // Check for any files except core files
+ canDelete &= PathHelpers.IsCoreName(Path.GetFileName(directoryEnumerator.Current));
- // If the flag changed (directory is not empty), break the loop
- if (!canDelete)
- break;
+ // If the flag changed (directory is not empty), break the loop
+ if (!canDelete)
+ break;
+ }
+ }
+ catch (DirectoryNotFoundException)
+ {
+ return Trace(DokanResult.PathNotFound, fileName, info);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Trace(DokanResult.AccessDenied, fileName, info);
}
var result = canDelete ? DokanResult.Success : DokanResult.DirectoryNotEmpty;
@@ -575,9 +584,9 @@ public override NtStatus MoveFile(string oldName, string newName, bool replace,
return Trace(DokanResult.AccessDenied, fileNameCombined, info);
}
- // File
- File.Delete(newCiphertextPath);
- File.Move(oldCiphertextPath, newCiphertextPath);
+ // Replace the destination file atomically. A separate delete and move
+ // could lose the destination file if the operation is interrupted in between
+ File.Replace(oldCiphertextPath, newCiphertextPath, null, true);
return Trace(DokanResult.Success, fileNameCombined, info);
}
@@ -758,7 +767,6 @@ public override NtStatus SetFileSecurity(string fileName, FileSystemSecurity sec
}
///
- [MethodImpl(MethodImplOptions.Synchronized)]
public override NtStatus ReadFile(string fileName, IntPtr buffer, uint bufferLength, out int bytesRead, long offset, IDokanFileInfo info)
{
try
@@ -775,18 +783,14 @@ public override NtStatus ReadFile(string fileName, IntPtr buffer, uint bufferLen
throw;
}
- catch (Exception ex)
+ catch (Exception)
{
- _ = ex;
bytesRead = 0;
- Debugger.Break();
-
return Trace(DokanResult.InternalError, fileName, info);
}
}
///
- [MethodImpl(MethodImplOptions.Synchronized)]
public override NtStatus WriteFile(string fileName, IntPtr buffer, uint bufferLength, out int bytesWritten, long offset, IDokanFileInfo info)
{
try
@@ -803,12 +807,9 @@ public override NtStatus WriteFile(string fileName, IntPtr buffer, uint bufferLe
throw;
}
- catch (Exception ex)
+ catch (Exception)
{
- _ = ex;
bytesWritten = 0;
-
- Debugger.Break();
return Trace(DokanResult.InternalError, fileName, info);
}
}
From 65ee83ef09109331b9c3dd248f23aeec45140d01 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 03:26:05 +0200
Subject: [PATCH 12/55] Fixed writing empty gap edge-case in PlaintextStream
---
.../Streams/PlaintextStream.cs | 57 ++++++++++++-------
1 file changed, 36 insertions(+), 21 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs b/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs
index 65ea8bf2d..ec776929c 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs
@@ -1,4 +1,9 @@
-using SecureFolderFS.Core.Cryptography;
+using System;
+using System.Buffers;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using SecureFolderFS.Core.Cryptography;
using SecureFolderFS.Core.FileSystem.Buffers;
using SecureFolderFS.Core.FileSystem.Chunks;
using SecureFolderFS.Core.FileSystem.Extensions;
@@ -6,11 +11,6 @@
using SecureFolderFS.Shared.Extensions;
using SecureFolderFS.Shared.Helpers;
using SecureFolderFS.Storage.VirtualFileSystem;
-using System;
-using System.IO;
-using System.Runtime.CompilerServices;
-using System.Security.Cryptography;
-using System.Threading;
namespace SecureFolderFS.Core.FileSystem.Streams
{
@@ -21,7 +21,6 @@ internal sealed class PlaintextStream : Stream, IWrapper
private readonly ChunkAccess _chunkAccess;
private readonly HeaderBuffer _headerBuffer;
private readonly Action _notifyStreamClosed;
- private readonly Lock _writeLock = new();
private long _length;
private long _position;
@@ -142,21 +141,35 @@ public override void Write(ReadOnlySpan buffer)
if (CanSeek && Position > Length)
{
- // Write gap
- var gapLength = Position - Length;
-
- // Generate cryptographically secure random bytes for gap filling
- var secureNoise = new byte[gapLength];
- RandomNumberGenerator.Fill(secureNoise);
+ // Fill the gap between the current length and the write position with zeros.
+ // Zeros keep sparse semantics consistent with SetLength-based extension,
+ // where a zeroed region also reads back as zeros.
+ var writePosition = Position;
+ var gapBuffer = ArrayPool.Shared.Rent(_security.ContentCrypt.ChunkPlaintextSize);
+ try
+ {
+ Array.Clear(gapBuffer, 0, gapBuffer.Length);
+
+ var gapPosition = Length;
+ while (gapPosition < writePosition)
+ {
+ var gapPart = (int)Math.Min(writePosition - gapPosition, gapBuffer.Length);
+ WriteInternal(gapBuffer.AsSpan(0, gapPart), gapPosition);
+ gapPosition += gapPart;
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(gapBuffer);
+ }
- // Write contents of a secure noise array
- WriteInternal(secureNoise, Length);
- }
- else
- {
- // Write contents
- WriteInternal(buffer, Position);
+ // WriteInternal advances the position by the amount written - restore
+ // it so the actual contents are written at the requested position
+ _position = writePosition;
}
+
+ // Write contents
+ WriteInternal(buffer, Position);
}
///
@@ -316,7 +329,9 @@ private bool TryWriteHeader()
if (_headerBuffer.IsHeaderReady)
return true;
- lock (_writeLock)
+ // The header buffer is shared by all streams of the same file,
+ // so lock on the buffer's synchronization root
+ lock (_headerBuffer.SyncRoot)
{
// Re-check after lock
if (_headerBuffer.IsHeaderReady)
From a2b752967d7324511bea5798425745eed806a94c Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 03:33:08 +0200
Subject: [PATCH 13/55] Improved reliability of reading Directory ID files +
formatting
---
.../Abstract/AbstractPathHelpers.Directory.cs | 21 ++++++++++++-------
.../Native/NativePathHelpers.Directory.cs | 7 ++++---
.../SecureFolderFS.Cli/CliCommandHelpers.cs | 2 --
.../SecureFolderFS.Cli/CliGlobalOptions.cs | 2 --
src/Platforms/SecureFolderFS.Cli/CliOutput.cs | 1 -
.../SecureFolderFS.Cli/CliTypeActivator.cs | 13 ------------
.../SecureFolderFS.Cli/CredentialReader.cs | 2 --
.../AndroidVaultCredentialsService.cs | 2 +-
.../ShareContentProvider.cs | 6 +++---
.../ServiceImplementation/IOSShareService.cs | 2 --
.../IOSVaultCredentialsService.cs | 2 +-
.../LoginTemplateSelector.cs | 2 +-
.../Vault/BrowserSearchModalPage.xaml.cs | 8 +++----
.../Modals/Vault/FilePreviewModalPage.xaml.cs | 2 +-
src/Platforms/SecureFolderFS.Uno/App.xaml.cs | 2 +-
.../Dialogs/PaymentDialog.xaml.cs | 2 +-
.../SkiaVaultCredentialsService.cs | 2 +-
.../LoginTemplateSelector.cs | 2 +-
.../UserControls/LoginOptions.xaml.cs | 6 +++---
.../CliTests/CliExecutionResult.cs | 2 --
.../CliTests/Commands/CredsAddCommandTests.cs | 2 --
.../Commands/CredsChangeCommandTests.cs | 2 --
.../Commands/CredsRemoveCommandTests.cs | 2 --
.../Commands/VaultCreateCommandTests.cs | 2 --
.../Commands/VaultInfoCommandTests.cs | 2 --
.../CliTests/Commands/VaultRunCommandTests.cs | 2 --
.../Commands/VaultShellCommandTests.cs | 2 --
27 files changed, 36 insertions(+), 66 deletions(-)
delete mode 100644 src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs
index d1efc6036..a72c7a820 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs
@@ -22,7 +22,8 @@ public static async Task GetDirectoryIdAsync(
var directoryIdFile = await folderOfDirectoryId.GetFileByNameAsync(Constants.Names.DIRECTORY_ID_FILENAME, cancellationToken).ConfigureAwait(false);
await using var directoryIdStream = await directoryIdFile.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken).ConfigureAwait(false);
- var read = await directoryIdStream.ReadAsync(directoryId, cancellationToken).ConfigureAwait(false);
+ // A single call to ReadAsync is not guaranteed to fill the buffer
+ var read = await directoryIdStream.ReadAtLeastAsync(directoryId, Constants.DIRECTORY_ID_SIZE, false, cancellationToken).ConfigureAwait(false);
if (read < Constants.DIRECTORY_ID_SIZE)
throw new IOException($"The data inside Directory ID file is of incorrect size: {read}.");
@@ -53,20 +54,26 @@ public static async Task GetDirectoryIdAsync(
var directoryIdFile = await folderOfDirectoryId.GetFileByNameAsync(Constants.Names.DIRECTORY_ID_FILENAME, cancellationToken).ConfigureAwait(false);
await using var directoryIdStream = await directoryIdFile.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken).ConfigureAwait(false);
+ // A single call to ReadAsync is not guaranteed to fill the buffer
int read;
if (specifics.DirectoryIdCache.IsAvailable)
{
cachedId = new(Constants.DIRECTORY_ID_SIZE);
- read = await directoryIdStream.ReadAsync(cachedId.Buffer, cancellationToken).ConfigureAwait(false);
- specifics.DirectoryIdCache.CacheSet(Path.Combine(folderOfDirectoryId.Id, Constants.Names.DIRECTORY_ID_FILENAME), cachedId);
+ read = await directoryIdStream.ReadAtLeastAsync(cachedId.Buffer, Constants.DIRECTORY_ID_SIZE, false, cancellationToken).ConfigureAwait(false);
+
+ // Validate the Directory ID before it is added to the cache
+ if (read < Constants.DIRECTORY_ID_SIZE)
+ throw new IOException($"The data inside Directory ID file is of incorrect size: {read}.");
+ specifics.DirectoryIdCache.CacheSet(Path.Combine(folderOfDirectoryId.Id, Constants.Names.DIRECTORY_ID_FILENAME), cachedId);
cachedId.Buffer.CopyTo(directoryId);
}
else
- read = await directoryIdStream.ReadAsync(directoryId, cancellationToken).ConfigureAwait(false);
-
- if (read < Constants.DIRECTORY_ID_SIZE)
- throw new IOException($"The data inside Directory ID file is of incorrect size: {read}.");
+ {
+ read = await directoryIdStream.ReadAtLeastAsync(directoryId, Constants.DIRECTORY_ID_SIZE, false, cancellationToken).ConfigureAwait(false);
+ if (read < Constants.DIRECTORY_ID_SIZE)
+ throw new IOException($"The data inside Directory ID file is of incorrect size: {read}.");
+ }
// The Directory ID is not empty - return true
return true;
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs
index 31dbb8f62..75533c37b 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs
@@ -24,12 +24,13 @@ public static bool GetDirectoryId(string ciphertextFolderPath, FileSystemSpecifi
return true;
}
- // Read the Directory ID from the file
+ // Read the Directory ID from the file.
+ // A single call to ReadAsync is not guaranteed to fill the buffer
using var directoryIdStream = File.Open(directoryIdPath, FileMode.Open, FileAccess.Read, FileShare.Read);
if (specifics.DirectoryIdCache.IsAvailable)
{
cachedDirectoryId = new(Constants.DIRECTORY_ID_SIZE);
- var read = directoryIdStream.Read(cachedDirectoryId);
+ var read = directoryIdStream.ReadAtLeast(cachedDirectoryId.Buffer, Constants.DIRECTORY_ID_SIZE, false);
if (read < Constants.DIRECTORY_ID_SIZE)
throw new IOException($"The data inside the Directory ID file is of incorrect size: {read}.");
@@ -38,7 +39,7 @@ public static bool GetDirectoryId(string ciphertextFolderPath, FileSystemSpecifi
}
else
{
- var read = directoryIdStream.Read(directoryId);
+ var read = directoryIdStream.ReadAtLeast(directoryId, Constants.DIRECTORY_ID_SIZE, false);
if (read < Constants.DIRECTORY_ID_SIZE)
throw new IOException($"The data inside the Directory ID file is of incorrect size: {read}.");
}
diff --git a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs
index 2f2d4bc78..34aab586f 100644
--- a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs
+++ b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs
@@ -77,5 +77,3 @@ public static int HandleException(Exception ex, IConsole console, CliGlobalOptio
}
}
}
-
-
diff --git a/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs b/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs
index 87810357e..e3f7290a1 100644
--- a/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs
+++ b/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs
@@ -14,5 +14,3 @@ public abstract class CliGlobalOptions : ICommand
public abstract ValueTask ExecuteAsync(IConsole console);
}
-
-
diff --git a/src/Platforms/SecureFolderFS.Cli/CliOutput.cs b/src/Platforms/SecureFolderFS.Cli/CliOutput.cs
index 43de10d27..df7d15737 100644
--- a/src/Platforms/SecureFolderFS.Cli/CliOutput.cs
+++ b/src/Platforms/SecureFolderFS.Cli/CliOutput.cs
@@ -50,4 +50,3 @@ public static string StripIfNeeded(CliGlobalOptions options, string value)
[GeneratedRegex("\\x1B\\[[0-9;]*[A-Za-z]")]
private static partial Regex AnsiRegex();
}
-
diff --git a/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs b/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs
deleted file mode 100644
index c823da7f3..000000000
--- a/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using Microsoft.Extensions.DependencyInjection;
-
-namespace SecureFolderFS.Cli;
-
-internal static class CliTypeActivator
-{
- public static object CreateInstance(IServiceProvider serviceProvider, Type type)
- {
- return ActivatorUtilities.CreateInstance(serviceProvider, type);
- }
-}
-
-
diff --git a/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs b/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs
index e79e9c13f..cc67c0f47 100644
--- a/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs
+++ b/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs
@@ -98,5 +98,3 @@ public async Task GenerateKeyFileAsync(string outputPath, string vaul
return buffer.Length == 0 ? null : buffer.ToString();
}
}
-
-
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs
index 002316b66..0545442bf 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs
@@ -28,7 +28,7 @@ public override async IAsyncEnumerable GetCreationAsync
await Task.CompletedTask;
}
-
+
///
protected override async IAsyncEnumerable GetLoginAsync(
IFolder vaultFolder,
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs
index 984bf2093..d20477d49 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs
@@ -28,7 +28,7 @@ public class ShareContentProvider : ContentProvider
/// to accommodate apps that open multiple streams (e.g. type sniffing + actual read).
///
private static readonly TimeSpan RegistrationTtl = TimeSpan.FromSeconds(20);
-
+
private static readonly Dictionary _registeredFiles = new();
private static readonly Lock _lock = new();
@@ -36,7 +36,7 @@ public class ShareContentProvider : ContentProvider
public ShareContentProvider()
{
}
-
+
///
/// Registers a file for sharing and returns a content URI suitable for use in an Intent.
/// The registration is automatically cleaned up after .
@@ -195,7 +195,7 @@ public override bool OnCreate()
///
public override int Update(Uri uri, ContentValues? values, string? selection, string[]? selectionArgs) => 0;
-
+
private static bool IsBrokenPipe(Java.IO.IOException ex)
{
var msg = ex.Message;
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs
index 840327ea1..9b2cc040d 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs
@@ -147,5 +147,3 @@ protected override void Dispose(bool disposing)
}
}
}
-
-
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs
index 883f2ce4d..ae8365b10 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs
@@ -34,7 +34,7 @@ public override async IAsyncEnumerable GetCreationAsync
await Task.CompletedTask;
}
-
+
///
protected override async IAsyncEnumerable GetLoginAsync(
IFolder vaultFolder,
diff --git a/src/Platforms/SecureFolderFS.Maui/TemplateSelectors/LoginTemplateSelector.cs b/src/Platforms/SecureFolderFS.Maui/TemplateSelectors/LoginTemplateSelector.cs
index e4629a446..5311e3b99 100644
--- a/src/Platforms/SecureFolderFS.Maui/TemplateSelectors/LoginTemplateSelector.cs
+++ b/src/Platforms/SecureFolderFS.Maui/TemplateSelectors/LoginTemplateSelector.cs
@@ -17,7 +17,7 @@ internal sealed class LoginTemplateSelector : DataTemplateSelector
public DataTemplate? KeyFileTemplate { get; set; }
public DataTemplate? PersistedAuthenticationTemplate { get; set; }
-
+
public DataTemplate? ErrorTemplate { get; set; }
public DataTemplate? UnsupportedTemplate { get; set; }
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/BrowserSearchModalPage.xaml.cs b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/BrowserSearchModalPage.xaml.cs
index 343cf1af0..0fef8abe4 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/BrowserSearchModalPage.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/BrowserSearchModalPage.xaml.cs
@@ -26,7 +26,7 @@ public BrowserSearchModalPage(INavigation sourceNavigation)
InitializeComponent();
}
-
+
///
public async Task ShowAsync()
{
@@ -79,7 +79,7 @@ protected override void OnDisappearing()
_modalTcs.TrySetResult(Result.Success);
}
-
+
[RelayCommand]
private async Task CloseAsync()
{
@@ -99,7 +99,7 @@ private async void ResultsCollectionView_SelectionChanged(object? sender, Select
await ViewModel.OpenSearchResultCommand.ExecuteAsync(selectedItem);
}
-
+
private async void SearchInput_SearchButtonPressed(object? sender, EventArgs e)
{
if (ViewModel is null)
@@ -112,7 +112,7 @@ private async void ViewModel_CloseRequested(object? sender, EventArgs e)
{
await HideAsync();
}
-
+
public BrowserSearchOverlayViewModel? ViewModel
{
get => (BrowserSearchOverlayViewModel?)GetValue(ViewModelProperty);
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml.cs b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml.cs
index a6e247c77..7fba25df4 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml.cs
@@ -152,7 +152,7 @@ private void MediaPlayerElement_Loaded(object? sender, EventArgs e)
{
if (audioViewModel.AudioSource is not ICollection audioDisposables || audioDisposables.ElementAtOrDefault(0) is not Stream audioStream)
return;
-
+
disposables = audioDisposables;
stream = audioStream;
}
diff --git a/src/Platforms/SecureFolderFS.Uno/App.xaml.cs b/src/Platforms/SecureFolderFS.Uno/App.xaml.cs
index 69cc7d41a..8af53e315 100644
--- a/src/Platforms/SecureFolderFS.Uno/App.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Uno/App.xaml.cs
@@ -194,7 +194,7 @@ private static bool IsShortcutFileActivation(AppActivationArguments? args)
return file is IStorageFile storageFile &&
storageFile.Path.EndsWith(UI.Constants.FileNames.VAULT_SHORTCUT_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase);
}
-
+
private static bool IsUriActivation(AppActivationArguments? args)
{
return args is { Kind: ExtendedActivationKind.Protocol, Data: IProtocolActivatedEventArgs };
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/PaymentDialog.xaml.cs b/src/Platforms/SecureFolderFS.Uno/Dialogs/PaymentDialog.xaml.cs
index c90707a93..b0690087e 100644
--- a/src/Platforms/SecureFolderFS.Uno/Dialogs/PaymentDialog.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/PaymentDialog.xaml.cs
@@ -45,7 +45,7 @@ private void PaymentDialog_Loaded(object sender, RoutedEventArgs e)
((Storyboard)Resources["OuterRingBreathe"]).Begin();
((Storyboard)Resources["InnerRingBreathe"]).Begin();
}
-
+
private void Close_Click(object sender, RoutedEventArgs e)
{
Hide();
diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs
index ad14c8323..cbf1f8fcc 100644
--- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs
@@ -49,7 +49,7 @@ public override async IAsyncEnumerable GetCreationAsync
await Task.CompletedTask;
}
-
+
///
protected override async IAsyncEnumerable GetLoginAsync(
IFolder vaultFolder,
diff --git a/src/Platforms/SecureFolderFS.Uno/TemplateSelectors/LoginTemplateSelector.cs b/src/Platforms/SecureFolderFS.Uno/TemplateSelectors/LoginTemplateSelector.cs
index 004aed59c..0d097a0a8 100644
--- a/src/Platforms/SecureFolderFS.Uno/TemplateSelectors/LoginTemplateSelector.cs
+++ b/src/Platforms/SecureFolderFS.Uno/TemplateSelectors/LoginTemplateSelector.cs
@@ -27,7 +27,7 @@ internal sealed class LoginTemplateSelector : BaseTemplateSelector (bool)GetValue(ShouldSaveCredentialsProperty);
@@ -27,7 +27,7 @@ public bool ShouldSaveCredentials
}
public static readonly DependencyProperty ShouldSaveCredentialsProperty =
DependencyProperty.Register(nameof(ShouldSaveCredentials), typeof(bool), typeof(LoginOptions), new PropertyMetadata(false));
-
+
public bool AreCredentialsSaved
{
get => (bool)GetValue(AreCredentialsSavedProperty);
@@ -59,7 +59,7 @@ public ICommand? DiscardSavedCredentialsCommand
}
public static readonly DependencyProperty DiscardSavedCredentialsCommandProperty =
DependencyProperty.Register(nameof(DiscardSavedCredentialsCommand), typeof(ICommand), typeof(LoginOptions), new PropertyMetadata(null));
-
+
public ICommand? RestartLoginCommand
{
get => (ICommand?)GetValue(RestartLoginCommandProperty);
diff --git a/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs b/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs
index a4d4e42b6..47359b899 100644
--- a/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs
+++ b/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs
@@ -5,5 +5,3 @@ public sealed record CliExecutionResult(
int ProcessExitCode,
string StandardOutput,
string StandardError);
-
-
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs
index da3c28180..f37145b02 100644
--- a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs
@@ -19,5 +19,3 @@ public async Task CredsAdd_WithRecoveryKeyFlag_ShouldReturnBadArguments()
result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments);
}
}
-
-
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs
index 0e8e99b09..9d793160d 100644
--- a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs
@@ -19,5 +19,3 @@ public async Task CredsChange_WithRecoveryKeyFlag_ShouldReturnBadArguments()
result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments);
}
}
-
-
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs
index 4191f582f..98836b726 100644
--- a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs
@@ -19,5 +19,3 @@ public async Task CredsRemove_WithRecoveryKeyFlag_ShouldReturnBadArguments()
result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments);
}
}
-
-
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs
index 968b30135..247450acc 100644
--- a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs
@@ -40,5 +40,3 @@ public async Task VaultCreate_WithPasswordEnvironmentVariable_ShouldCreateVault(
File.Exists(configPath).Should().BeTrue();
}
}
-
-
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs
index 82e1a496d..43427c54c 100644
--- a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs
@@ -35,5 +35,3 @@ public async Task VaultInfo_OnExistingVault_ShouldReturnInformation()
Assert.Pass();
}
}
-
-
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs
index 6ed58c267..794590881 100644
--- a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs
@@ -19,5 +19,3 @@ public async Task VaultRun_WithoutReadOrWrite_ShouldReturnBadArguments()
result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments);
}
}
-
-
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs
index edd046846..7520afd15 100644
--- a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs
@@ -19,5 +19,3 @@ public async Task VaultShell_WithoutAnyCredential_ShouldReturnBadArguments()
result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments);
}
}
-
-
From 42338a44aca161d15d6e15ffe84473d65681efab Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 03:47:48 +0200
Subject: [PATCH 14/55] Lock HeaderBuffer.SyncRoot on reading header
---
.../Extensions/FileHeaderExtensions.cs | 60 +++++++++++--------
.../Abstract/AbstractPathHelpers.Directory.cs | 6 +-
2 files changed, 37 insertions(+), 29 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs
index 83c92ec58..64d49b00a 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs
@@ -18,34 +18,42 @@ public static bool ReadHeader(this HeaderBuffer headerBuffer, Stream ciphertextS
if (!ciphertextStream.CanRead)
throw FileSystemExceptions.StreamNotReadable;
- // Allocate ciphertext header
- Span ciphertextHeader = stackalloc byte[security.HeaderCrypt.HeaderCiphertextSize];
-
- // Read header
- int read;
- if (ciphertextStream.CanSeek && ciphertextStream.Position != 0L)
- {
- var ciphertextPosition = ciphertextStream.Position;
- ciphertextStream.Position = 0L;
-
- read = ciphertextStream.Read(ciphertextHeader);
- ciphertextStream.Position = ciphertextPosition;
- }
- else
+ // The header buffer is shared by all streams of the same file, so a lock is needed
+ lock (headerBuffer.SyncRoot)
{
- // Non-seekable streams must be at position 0 - header is always read first sequentially.
- // There is no way to rewind, so we simply read and continue.
- read = ciphertextStream.Read(ciphertextHeader);
+ // Re-check after lock
+ if (headerBuffer.IsHeaderReady)
+ return true;
+
+ // Allocate ciphertext header
+ Span ciphertextHeader = stackalloc byte[security.HeaderCrypt.HeaderCiphertextSize];
+
+ // Read header
+ int read;
+ if (ciphertextStream.CanSeek && ciphertextStream.Position != 0L)
+ {
+ var ciphertextPosition = ciphertextStream.Position;
+ ciphertextStream.Position = 0L;
+
+ read = ciphertextStream.Read(ciphertextHeader);
+ ciphertextStream.Position = ciphertextPosition;
+ }
+ else
+ {
+ // Non-seekable streams must be at position 0 - header is always read first sequentially.
+ // There is no way to rewind, so we simply read and continue.
+ read = ciphertextStream.Read(ciphertextHeader);
+ }
+
+ // Check if the read amount is correct
+ if (read < ciphertextHeader.Length)
+ return false;
+
+ // Decrypt header
+ headerBuffer.IsHeaderReady = security.HeaderCrypt.DecryptHeader(ciphertextHeader, headerBuffer);
+
+ return headerBuffer.IsHeaderReady;
}
-
- // Check if the read amount is correct
- if (read < ciphertextHeader.Length)
- return false;
-
- // Decrypt header
- headerBuffer.IsHeaderReady = security.HeaderCrypt.DecryptHeader(ciphertextHeader, headerBuffer);
-
- return headerBuffer.IsHeaderReady;
}
}
}
\ No newline at end of file
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs
index a72c7a820..69deac032 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs
@@ -1,10 +1,10 @@
-using OwlCore.Storage;
-using SecureFolderFS.Shared.Models;
-using SecureFolderFS.Storage.Extensions;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
+using OwlCore.Storage;
+using SecureFolderFS.Shared.Models;
+using SecureFolderFS.Storage.Extensions;
namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract
{
From 722dc8b4c638fe617cac99eda6a1a75f57aa25c4 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 03:57:00 +0200
Subject: [PATCH 15/55] Corrected Dokany file region locking
---
.../DokanyFileSystem.cs | 2 +-
.../OpenHandles/DokanyFileHandle.cs | 43 +++++++++++++++++--
.../OpenHandles/DokanyHandlesManager.cs | 12 ++++--
3 files changed, 49 insertions(+), 8 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs b/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs
index 71daa46b9..01a85a91c 100644
--- a/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs
+++ b/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs
@@ -54,7 +54,7 @@ public async Task MountAsync(IFolder folder, IDisposable unlockContrac
if (dokanyOptions.MountPoint is null)
throw new DirectoryNotFoundException("No available free mount points for vault file system.");
- var handlesManager = new DokanyHandlesManager(specifics.StreamsAccess, specifics.Options);
+ var handlesManager = new DokanyHandlesManager(wrapper.Inner, specifics.StreamsAccess, specifics.Options);
var volumeModel = new VolumeModel(specifics.Options.VolumeName, Constants.Dokan.FS_TYPE_ID);
var dokanyCallbacks = new OnDeviceDokany(specifics, handlesManager, volumeModel);
var dokanyWrapper = new DokanyWrapper(dokanyCallbacks);
diff --git a/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyFileHandle.cs b/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyFileHandle.cs
index cca1c49ac..5ace9b529 100644
--- a/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyFileHandle.cs
+++ b/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyFileHandle.cs
@@ -1,4 +1,5 @@
using Microsoft.Win32.SafeHandles;
+using SecureFolderFS.Core.Cryptography;
using SecureFolderFS.Core.Dokany.UnsafeNative;
using SecureFolderFS.Core.FileSystem.OpenHandles;
using SecureFolderFS.Shared.ComponentModel;
@@ -11,9 +12,12 @@ namespace SecureFolderFS.Core.Dokany.OpenHandles
///
internal sealed class DokanyFileHandle : FileHandle
{
- public DokanyFileHandle(Stream stream)
+ private readonly Security _security;
+
+ public DokanyFileHandle(Stream stream, Security security)
: base(stream)
{
+ _security = security;
}
///
@@ -46,14 +50,47 @@ public bool SetFileTime(ref long creationTime, ref long lastAccessTime, ref long
public void Lock(long position, long length)
{
if (Stream is IWrapper { Inner: FileStream fileStream })
- fileStream.Lock(position, length);
+ {
+ var (ciphertextPosition, ciphertextLength) = TranslateToCiphertextRange(position, length);
+ fileStream.Lock(ciphertextPosition, ciphertextLength);
+ }
}
///
public void Unlock(long position, long length)
{
if (Stream is IWrapper { Inner: FileStream fileStream })
- fileStream.Unlock(position, length);
+ {
+ var (ciphertextPosition, ciphertextLength) = TranslateToCiphertextRange(position, length);
+ fileStream.Unlock(ciphertextPosition, ciphertextLength);
+ }
+ }
+
+ ///
+ /// Translates a plaintext byte range to the ciphertext range covering the affected chunks.
+ ///
+ ///
+ /// Plaintext offsets cannot be applied to the ciphertext file directly as the file starts
+ /// with the header, and each chunk carries additional overhead. Locking untranslated
+ /// offsets would lock the header (offset 0) and misalign every subsequent range.
+ /// The translation is deterministic, so a matching releases the exact same range.
+ ///
+ private (long Position, long Length) TranslateToCiphertextRange(long plaintextPosition, long plaintextLength)
+ {
+ var chunkPlaintextSize = (long)_security.ContentCrypt.ChunkPlaintextSize;
+ var chunkCiphertextSize = (long)_security.ContentCrypt.ChunkCiphertextSize;
+ var headerCiphertextSize = (long)_security.HeaderCrypt.HeaderCiphertextSize;
+
+ // Always cover at least the chunk containing the start position
+ var firstChunk = plaintextPosition / chunkPlaintextSize;
+ var lastChunkExclusive = plaintextLength <= 0L
+ ? firstChunk + 1L
+ : (plaintextPosition + plaintextLength + chunkPlaintextSize - 1L) / chunkPlaintextSize;
+
+ var ciphertextPosition = headerCiphertextSize + firstChunk * chunkCiphertextSize;
+ var ciphertextLength = (lastChunkExclusive - firstChunk) * chunkCiphertextSize;
+
+ return (ciphertextPosition, ciphertextLength);
}
}
}
diff --git a/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs b/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs
index c853fbf36..edcfc4dcc 100644
--- a/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs
+++ b/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs
@@ -1,17 +1,21 @@
-using SecureFolderFS.Core.FileSystem.Extensions;
+using System.IO;
+using SecureFolderFS.Core.Cryptography;
+using SecureFolderFS.Core.FileSystem.Extensions;
using SecureFolderFS.Core.FileSystem.OpenHandles;
using SecureFolderFS.Core.FileSystem.Streams;
using SecureFolderFS.Storage.VirtualFileSystem;
-using System.IO;
namespace SecureFolderFS.Core.Dokany.OpenHandles
{
///
internal sealed class DokanyHandlesManager : BaseHandlesManager
{
- public DokanyHandlesManager(StreamsAccess streamsAccess, VirtualFileSystemOptions fileSystemOptions)
+ private readonly Security _security;
+
+ public DokanyHandlesManager(Security security, StreamsAccess streamsAccess, VirtualFileSystemOptions fileSystemOptions)
: base(streamsAccess, fileSystemOptions)
{
+ _security = security;
}
///
@@ -37,7 +41,7 @@ public override ulong OpenFileHandle(string ciphertextPath, FileMode mode, FileA
plaintextStream.Flush();
// Create handle
- var fileHandle = new DokanyFileHandle(plaintextStream);
+ var fileHandle = new DokanyFileHandle(plaintextStream, _security);
var handleId = handlesGenerator.ThreadSafeIncrement();
// Lock collection and add handle
From 44046cd8bba0f705627a84df4aec30cafda350d7 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 04:25:44 +0200
Subject: [PATCH 16/55] Improved WinFsp enumeration performance
---
.../Chunks/ChunkReader.cs | 4 -
.../AppModels/DirectoryEnumerationContext.cs | 20 ++
.../Callbacks/OnDeviceWinFsp.cs | 334 ++++++++++--------
3 files changed, 207 insertions(+), 151 deletions(-)
create mode 100644 src/Core/SecureFolderFS.Core.WinFsp/AppModels/DirectoryEnumerationContext.cs
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs
index 19828376d..53eb7cfb7 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs
@@ -1,6 +1,5 @@
using System;
using System.Buffers;
-using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using SecureFolderFS.Core.Cryptography;
@@ -91,10 +90,7 @@ public int ReadChunk(long chunkNumber, Span plaintextChunk)
// Check if the chunk is authentic
if (!result)
- {
- Debugger.Break();
return -1;
- }
return read - (ciphertextSize - plaintextSize);
}
diff --git a/src/Core/SecureFolderFS.Core.WinFsp/AppModels/DirectoryEnumerationContext.cs b/src/Core/SecureFolderFS.Core.WinFsp/AppModels/DirectoryEnumerationContext.cs
new file mode 100644
index 000000000..852341785
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.WinFsp/AppModels/DirectoryEnumerationContext.cs
@@ -0,0 +1,20 @@
+using System.Collections.Generic;
+using FileInfo = Fsp.Interop.FileInfo;
+
+namespace SecureFolderFS.Core.WinFsp.AppModels
+{
+ ///
+ /// Holds the state of a single directory enumeration session.
+ ///
+ internal sealed class DirectoryEnumerationContext
+ {
+ public List<(string PlaintextName, FileInfo FileInfo)> Entries { get; }
+
+ public int Index { get; set; }
+
+ public DirectoryEnumerationContext(List<(string PlaintextName, FileInfo FileInfo)> entries)
+ {
+ Entries = entries;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs
index e3f190434..487c5fcb9 100644
--- a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs
+++ b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs
@@ -1,4 +1,9 @@
-using Fsp;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Security.AccessControl;
+using Fsp;
using Fsp.Interop;
using OwlCore.Storage;
using SecureFolderFS.Core.FileSystem;
@@ -9,16 +14,13 @@
using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native;
using SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native;
using SecureFolderFS.Core.FileSystem.OpenHandles;
+using SecureFolderFS.Core.WinFsp.AppModels;
using SecureFolderFS.Core.WinFsp.OpenHandles;
using SecureFolderFS.Core.WinFsp.UnsafeNative;
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.IO;
-using System.Runtime.CompilerServices;
-using System.Security.AccessControl;
using FileInfo = Fsp.Interop.FileInfo;
+#pragma warning disable CA1416 // Validate platform compatibility
+
namespace SecureFolderFS.Core.WinFsp.Callbacks
{
public sealed partial class OnDeviceWinFsp : FileSystemBase, IDisposable
@@ -148,75 +150,76 @@ public override bool ReadDirectoryEntry(
return false;
}
- // Default pattern = "*"
- Pattern = !string.IsNullOrEmpty(Pattern) ? Pattern.Replace('<', '*').Replace('>', '?').Replace('"', '.') : "*";
+ // The directory contents are enumerated once per session and kept in the Context.
+ // Re-enumerating (and re-decrypting every name) on each produced entry would make
+ // directory listings quadratic in the number of items.
+ if (Context is not DirectoryEnumerationContext enumerationContext)
+ {
+ // Default pattern: "*"
+ Pattern = !string.IsNullOrEmpty(Pattern) ? Pattern.Replace('<', '*').Replace('>', '?').Replace('"', '.') : "*";
- var entries = new List<(string plaintextName, FileSystemInfo info)>();
- var dirInfo = dirHandle.DirectoryInfo;
+ var entries = new List<(string PlaintextName, FileInfo FileInfo)>();
+ var dirInfo = dirHandle.DirectoryInfo;
- try
- {
- // Add directory entries
- var directoryId = AbstractPathHelpers.AllocateDirectoryId(_specifics.Security, dirInfo.FullName);
- var itemsEnumerable = _specifics.Security.NameCrypt is null ? dirInfo.EnumerateFileSystemInfos(Pattern) : dirInfo.EnumerateFileSystemInfos();
- foreach (var item in itemsEnumerable)
+ try
{
- if (PathHelpers.IsCoreName(item.Name))
- continue;
+ // Add directory entries
+ var directoryId = AbstractPathHelpers.AllocateDirectoryId(_specifics.Security, dirInfo.FullName);
+ var itemsEnumerable = _specifics.Security.NameCrypt is null ? dirInfo.EnumerateFileSystemInfos(Pattern) : dirInfo.EnumerateFileSystemInfos();
+ foreach (var item in itemsEnumerable)
+ {
+ if (PathHelpers.IsCoreName(item.Name))
+ continue;
- var plaintextName = NativePathHelpers.DecryptName(item.Name, dirInfo.FullName, _specifics, directoryId);
- if (string.IsNullOrEmpty(plaintextName))
- continue;
+ var plaintextName = NativePathHelpers.DecryptName(item.Name, dirInfo.FullName, _specifics, directoryId);
+ if (string.IsNullOrEmpty(plaintextName))
+ continue;
- if (_specifics.Security.NameCrypt is not null && !UnsafeNativeApis.PathMatchSpec(plaintextName, Pattern))
- continue;
+ if (_specifics.Security.NameCrypt is not null && !UnsafeNativeApis.PathMatchSpec(plaintextName, Pattern))
+ continue;
- entries.Add((plaintextName, item));
+ entries.Add(item switch
+ {
+ System.IO.FileInfo fi => (plaintextName, WinFspFileHandle.ToFileInfo(fi, _specifics.Security)),
+ DirectoryInfo di => (plaintextName, WinFspDirectoryHandle.ToFileInfo(di)),
+ _ => throw new ArgumentOutOfRangeException(nameof(item))
+ });
+ }
}
- }
- catch (Exception)
- {
- FileName = null;
- FileInfo = default;
-
- return false;
- }
+ catch (Exception)
+ {
+ FileName = null;
+ FileInfo = default;
- // Sort alphabetically for consistent enumeration
- entries.Sort((a, b) => string.Compare(a.info.Name, b.info.Name, StringComparison.OrdinalIgnoreCase));
+ return false;
+ }
- // Determine current enumeration index
- int index;
- if (Context is null)
- {
- index = 0;
+ // Sort by the names reported to the caller for consistent enumeration.
+ // The marker WinFsp passes when resuming is the last *plaintext* name returned,
+ // so both sorting and marker matching must use plaintext names.
+ entries.Sort((a, b) => string.CompareOrdinal(a.PlaintextName, b.PlaintextName));
- // If a marker was provided, start *after* that entry
+ // If a marker was provided, resume after that entry. When the marker is gone
+ // (e.g., the item was deleted in the meantime), resume at the next greater name.
+ var index = 0;
if (!string.IsNullOrEmpty(Marker))
{
- index = entries.FindIndex(e => string.Equals(e.info.Name, Marker, StringComparison.OrdinalIgnoreCase));
- if (index >= 0)
- index++;
- else
- index = 0;
+ index = entries.FindIndex(e => string.CompareOrdinal(e.PlaintextName, Marker) > 0);
+ if (index < 0)
+ index = entries.Count;
}
+
+ Context = enumerationContext = new DirectoryEnumerationContext(entries) { Index = index };
}
- else
- index = (int)Context;
// Produce the next entry, if any
- if (index < entries.Count)
+ if (enumerationContext.Index < enumerationContext.Entries.Count)
{
- var entry = entries[index];
- FileName = entry.plaintextName;
- FileInfo = entry.info switch
- {
- System.IO.FileInfo fi => WinFspFileHandle.ToFileInfo(fi, _specifics.Security),
- DirectoryInfo di => WinFspDirectoryHandle.ToFileInfo(di),
- _ => throw new ArgumentOutOfRangeException(nameof(entry.info))
- };
+ var entry = enumerationContext.Entries[enumerationContext.Index];
+ FileName = entry.PlaintextName;
+ FileInfo = entry.FileInfo;
- Context = index + 1;
+ enumerationContext.Index++;
return true;
}
@@ -293,6 +296,11 @@ public override int GetSecurityByName(
FileAttributes = 0;
return Trace(STATUS_OBJECT_PATH_NOT_FOUND, FileName);
}
+ catch (UnauthorizedAccessException)
+ {
+ FileAttributes = 0;
+ return Trace(STATUS_ACCESS_DENIED, FileName);
+ }
}
///
@@ -316,7 +324,6 @@ public override int GetSecurity(
}
///
- [MethodImpl(MethodImplOptions.Synchronized)]
public override unsafe int Read(
object FileNode,
object FileDesc,
@@ -332,21 +339,26 @@ public override unsafe int Read(
return Trace(STATUS_INVALID_HANDLE);
}
- // Check EOF
- if (Offset >= (ulong)fileHandle.Stream.Length)
- {
- BytesTransferred = 0u;
- return Trace(STATUS_END_OF_FILE);
- }
-
try
{
- // Align position
- fileHandle.Stream.Position = (long)Offset;
+ // Lock on the handle's stream. The kernel can issue concurrent operations
+ // on the same handle, and setting the position and reading must be atomic
+ lock (fileHandle.Stream)
+ {
+ // Check EOF
+ if (Offset >= (ulong)fileHandle.Stream.Length)
+ {
+ BytesTransferred = 0u;
+ return Trace(STATUS_END_OF_FILE);
+ }
- // Read file
- var bufferSpan = new Span(Buffer.ToPointer(), (int)Length);
- BytesTransferred = (uint)fileHandle.Stream.Read(bufferSpan);
+ // Align position
+ fileHandle.Stream.Position = (long)Offset;
+
+ // Read file
+ var bufferSpan = new Span(Buffer.ToPointer(), (int)Length);
+ BytesTransferred = (uint)fileHandle.Stream.Read(bufferSpan);
+ }
return Trace(STATUS_SUCCESS);
}
@@ -361,7 +373,6 @@ public override unsafe int Read(
}
///
- [MethodImpl(MethodImplOptions.Synchronized)]
public override unsafe int Write(
object FileNode,
object FileDesc,
@@ -390,34 +401,39 @@ public override unsafe int Write(
return Trace(STATUS_INVALID_HANDLE);
}
- // Check constrained I/O
- if (ConstrainedIo)
+ try
{
- // If offset is beyond EOF, no bytes can be written in Constrained I/O mode
- if (Offset >= (ulong)fileHandle.Stream.Length)
+ // Lock on the handle's stream. The kernel can issue concurrent operations
+ // on the same handle, and setting the position and writing must be atomic
+ lock (fileHandle.Stream)
{
- FileInfo = default;
- BytesTransferred = 0u;
+ // Check constrained I/O
+ if (ConstrainedIo)
+ {
+ // If offset is beyond EOF, no bytes can be written in Constrained I/O mode
+ if (Offset >= (ulong)fileHandle.Stream.Length)
+ {
+ FileInfo = default;
+ BytesTransferred = 0u;
- return Trace(STATUS_SUCCESS);
- }
+ return Trace(STATUS_SUCCESS);
+ }
- if ((Offset + Length) > (ulong)fileHandle.Stream.Length)
- Length = (uint)((ulong)fileHandle.Stream.Length - Offset);
- }
+ if ((Offset + Length) > (ulong)fileHandle.Stream.Length)
+ Length = (uint)((ulong)fileHandle.Stream.Length - Offset);
+ }
- try
- {
- // Align position
- fileHandle.Stream.Position = WriteToEndOfFile ? fileHandle.Stream.Length : (long)Offset;
+ // Align position
+ fileHandle.Stream.Position = WriteToEndOfFile ? fileHandle.Stream.Length : (long)Offset;
- // Write file
- var bufferSpan = new ReadOnlySpan(Buffer.ToPointer(), (int)Length);
- fileHandle.Stream.Write(bufferSpan);
+ // Write file
+ var bufferSpan = new ReadOnlySpan(Buffer.ToPointer(), (int)Length);
+ fileHandle.Stream.Write(bufferSpan);
- // Set transferred bytes and update file info
- BytesTransferred = Length;
- FileInfo = fileHandle.GetFileInfo();
+ // Set transferred bytes and update file info
+ BytesTransferred = Length;
+ FileInfo = fileHandle.GetFileInfo();
+ }
return Trace(STATUS_SUCCESS);
}
@@ -485,43 +501,39 @@ public override int SetBasicInfo(
return Trace(STATUS_ACCESS_DENIED);
}
- FileAttributes = FileAttributes switch
+ var handle = _handlesManager.GetHandle(GetContextValue(FileDesc));
+ FileSystemInfo? info = handle switch
{
- Constants.UnsafeNative.INVALID_FILE_ATTRIBUTES => (uint)System.IO.FileAttributes.None,
- (uint)System.IO.FileAttributes.None => (uint)System.IO.FileAttributes.Normal,
- _ => FileAttributes
+ WinFspFileHandle fileHandle => fileHandle.FileInfo,
+ WinFspDirectoryHandle dirHandle => dirHandle.DirectoryInfo,
+ _ => null
};
- switch (_handlesManager.GetHandle(GetContextValue(FileDesc)))
+ if (info is null)
{
- case WinFspFileHandle fileHandle:
- {
- fileHandle.FileInfo.CreationTimeUtc = DateTime.FromFileTimeUtc((long)CreationTime);
- fileHandle.FileInfo.LastWriteTimeUtc = DateTime.FromFileTimeUtc((long)LastWriteTime);
- fileHandle.FileInfo.LastAccessTimeUtc = DateTime.FromFileTimeUtc((long)LastAccessTime);
- fileHandle.FileInfo.Attributes = (FileAttributes)FileAttributes;
- FileInfo = fileHandle.GetFileInfo();
+ FileInfo = default;
+ return Trace(STATUS_INVALID_HANDLE);
+ }
- break;
- }
+ // A zero time value and INVALID_FILE_ATTRIBUTES indicate that the field must not be changed
+ if (FileAttributes != Constants.UnsafeNative.INVALID_FILE_ATTRIBUTES)
+ info.Attributes = FileAttributes == (uint)System.IO.FileAttributes.None ? System.IO.FileAttributes.Normal : (FileAttributes)FileAttributes;
- case WinFspDirectoryHandle dirHandle:
- {
- dirHandle.DirectoryInfo.CreationTimeUtc = DateTime.FromFileTimeUtc((long)CreationTime);
- dirHandle.DirectoryInfo.LastWriteTimeUtc = DateTime.FromFileTimeUtc((long)LastWriteTime);
- dirHandle.DirectoryInfo.LastAccessTimeUtc = DateTime.FromFileTimeUtc((long)LastAccessTime);
- dirHandle.DirectoryInfo.Attributes = (FileAttributes)FileAttributes;
- FileInfo = dirHandle.GetFileInfo();
+ if (CreationTime != 0UL)
+ info.CreationTimeUtc = DateTime.FromFileTimeUtc((long)CreationTime);
- break;
- }
+ if (LastAccessTime != 0UL)
+ info.LastAccessTimeUtc = DateTime.FromFileTimeUtc((long)LastAccessTime);
- default:
- {
- FileInfo = default;
- return Trace(STATUS_INVALID_HANDLE);
- }
- }
+ if (LastWriteTime != 0UL)
+ info.LastWriteTimeUtc = DateTime.FromFileTimeUtc((long)LastWriteTime);
+
+ FileInfo = handle switch
+ {
+ WinFspFileHandle fileHandle => fileHandle.GetFileInfo(),
+ WinFspDirectoryHandle dirHandle => dirHandle.GetFileInfo(),
+ _ => default
+ };
return Trace(STATUS_SUCCESS);
}
@@ -549,11 +561,15 @@ public override int SetFileSize(
try
{
- // If the new AllocationSize is less than the current FileSize we must truncate the file
- if (!SetAllocationSize || (ulong)fileHandle.Stream.Length > NewSize)
- fileHandle.Stream.SetLength((long)NewSize);
+ lock (fileHandle.Stream)
+ {
+ // If the new AllocationSize is less than the current FileSize we must truncate the file
+ if (!SetAllocationSize || (ulong)fileHandle.Stream.Length > NewSize)
+ fileHandle.Stream.SetLength((long)NewSize);
+
+ FileInfo = fileHandle.GetFileInfo();
+ }
- FileInfo = fileHandle.GetFileInfo();
return Trace(STATUS_SUCCESS);
}
catch (IOException ioEx)
@@ -639,8 +655,11 @@ public override int Flush(
try
{
- fileHandle.Stream.Flush();
- FileInfo = fileHandle.GetFileInfo();
+ lock (fileHandle.Stream)
+ {
+ fileHandle.Stream.Flush();
+ FileInfo = fileHandle.GetFileInfo();
+ }
return Trace(STATUS_SUCCESS);
}
@@ -680,7 +699,8 @@ public override int Create(
return Trace(STATUS_ACCESS_DENIED, FileName);
}
- IDisposable? handle = null;
+ IDisposable? handle;
+ var createdHandleId = FileSystem.Constants.INVALID_HANDLE;
try
{
var ciphertextPath = GetCiphertextPath(FileName);
@@ -700,6 +720,7 @@ public override int Create(
fileAccess,
FileShare.ReadWrite | FileShare.Delete,
FileOptions.None);
+ createdHandleId = handleId;
if (_handlesManager.GetHandle(handleId) is not { } fileHandle)
{
@@ -726,8 +747,7 @@ public override int Create(
}
}
- fileHandle.FileInfo.Attributes =
- (System.IO.FileAttributes)(FileAttributes | (uint)System.IO.FileAttributes.Archive);
+ fileHandle.FileInfo.Attributes = (FileAttributes)(FileAttributes | (uint)System.IO.FileAttributes.Archive);
handle = fileHandle;
}
else
@@ -765,6 +785,8 @@ public override int Create(
_specifics.DirectoryIdCache.CacheSet(directoryIdPath, new(directoryId));
var handleId = _handlesManager.OpenDirectoryHandle(ciphertextPath);
+ createdHandleId = handleId;
+
if (_handlesManager.GetHandle(handleId) is not { } dirHandle)
{
FileNode = null;
@@ -790,7 +812,7 @@ public override int Create(
}
}
- directoryInfo.Attributes = (System.IO.FileAttributes)FileAttributes;
+ directoryInfo.Attributes = (FileAttributes)FileAttributes;
handle = dirHandle;
}
@@ -820,7 +842,7 @@ public override int Create(
FileInfo = default;
NormalizedName = null;
- handle?.Dispose();
+ _handlesManager.CloseHandle(createdHandleId);
if (ErrorHandlingHelpers.IsFileAlreadyExistsException(ioEx))
return Trace(STATUS_OBJECT_NAME_COLLISION, FileName);
@@ -853,23 +875,40 @@ public override int Overwrite(
return Trace(STATUS_INVALID_HANDLE);
}
- if (ReplaceFileAttributes)
- {
- fileHandle.FileInfo.Attributes = (System.IO.FileAttributes)(FileAttributes | (uint)System.IO.FileAttributes.Archive);
- }
- else if (FileAttributes != 0u)
+ try
{
- var existingAttributes = fileHandle.FileInfo.Attributes;
- existingAttributes |= (System.IO.FileAttributes)FileAttributes;
- existingAttributes |= System.IO.FileAttributes.Archive;
+ if (ReplaceFileAttributes)
+ {
+ fileHandle.FileInfo.Attributes = (FileAttributes)(FileAttributes | (uint)System.IO.FileAttributes.Archive);
+ }
+ else if (FileAttributes != 0u)
+ {
+ var existingAttributes = fileHandle.FileInfo.Attributes;
+ existingAttributes |= (FileAttributes)FileAttributes;
+ existingAttributes |= System.IO.FileAttributes.Archive;
- fileHandle.FileInfo.Attributes = existingAttributes;
+ fileHandle.FileInfo.Attributes = existingAttributes;
+ }
+
+ lock (fileHandle.Stream)
+ {
+ fileHandle.Stream.SetLength(0L);
+ FileInfo = fileHandle.GetFileInfo();
+ }
+
+ return Trace(STATUS_SUCCESS);
}
+ catch (Exception ex)
+ {
+ FileInfo = default;
+ if (ex is IOException ioEx && ErrorHandlingHelpers.IsDiskFullException(ioEx))
+ return Trace(STATUS_DISK_FULL);
- fileHandle.Stream.SetLength(0L);
- FileInfo = fileHandle.GetFileInfo();
+ if (ErrorHandlingHelpers.Win32ErrorFromException(ex, out var win32error))
+ return Trace(NtStatusFromWin32(win32error));
- return Trace(STATUS_SUCCESS);
+ return Trace(STATUS_ACCESS_DENIED);
+ }
}
///
@@ -1025,8 +1064,9 @@ public override int Rename(
return Trace(STATUS_ACCESS_DENIED, FileName);
}
- File.Delete(newCiphertextPath);
- File.Move(oldCiphertextPath, newCiphertextPath);
+ // Replace the destination file atomically. A separate delete and move
+ // could lose the destination file if the operation is interrupted in between
+ File.Replace(oldCiphertextPath, newCiphertextPath, null, true);
return Trace(STATUS_SUCCESS, FileName);
}
From 3ed1bb7ada97e3d15d4f46d07882951ef7359089 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 05:11:09 +0200
Subject: [PATCH 17/55] Moved GenerateVideoThumbnailAsync to ThumbnailHelpers
---
.../Android/AppModels/StreamedMediaSource.cs | 2 +-
.../Android/Helpers/ThumbnailHelpers.cs | 32 +++++++++++++++++--
.../SecureFolderFS.Core.MobileFS.csproj | 6 ----
.../AndroidMediaService.cs | 17 +---------
.../Android/Storage/AndroidStorable.cs | 2 --
5 files changed, 31 insertions(+), 28 deletions(-)
rename src/{Platforms/SecureFolderFS.Maui => Core/SecureFolderFS.Core.MobileFS}/Platforms/Android/AppModels/StreamedMediaSource.cs (94%)
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/StreamedMediaSource.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AppModels/StreamedMediaSource.cs
similarity index 94%
rename from src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/StreamedMediaSource.cs
rename to src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AppModels/StreamedMediaSource.cs
index bff7afcbf..7710606cc 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/StreamedMediaSource.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AppModels/StreamedMediaSource.cs
@@ -1,7 +1,7 @@
using Android.Media;
using Stream = System.IO.Stream;
-namespace SecureFolderFS.Maui.AppModels
+namespace SecureFolderFS.Core.MobileFS.AppModels
{
///
internal sealed class StreamedMediaSource : MediaDataSource
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs
index be5870d16..9f5197d68 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs
@@ -1,5 +1,6 @@
using Android.Graphics;
using Android.Media;
+using SecureFolderFS.Core.MobileFS.AppModels;
using SecureFolderFS.Storage.Streams;
using ExifInterface = AndroidX.ExifInterface.Media.ExifInterface;
using Stream = System.IO.Stream;
@@ -8,6 +9,14 @@ namespace SecureFolderFS.Core.MobileFS.Platforms.Android.Helpers
{
public static class ThumbnailHelpers
{
+ private const int IMAGE_THUMBNAIL_QUALITY = 70;
+
+ ///
+ /// Generates a scaled-down thumbnail from the provided image file stream with a maximum size constraint.
+ ///
+ /// The image file stream from which the thumbnail is generated.
+ /// The maximum size (width or height) that the thumbnail should not exceed.
+ /// A that represents the asynchronous operation. Value is a stream containing the compressed thumbnail image.
public static async Task GenerateImageThumbnailAsync(Stream stream, uint maxSize)
{
// Read EXIF
@@ -50,6 +59,25 @@ public static async Task GenerateImageThumbnailAsync(Stream stream, uint
return await CompressBitmapAsync(rotated).ConfigureAwait(false);
}
+ ///
+ /// Generates a video thumbnail as a compressed stream by capturing a frame at the specified timestamp.
+ ///
+ /// The video file stream from which the thumbnail is generated.
+ /// The time position in the video to capture the thumbnail frame.
+ /// A that represents the asynchronous operation. Value is a stream containing the compressed thumbnail image.
+ public static async Task GenerateVideoThumbnailAsync(Stream stream, TimeSpan captureTime)
+ {
+ using var retriever = new MediaMetadataRetriever();
+ await retriever.SetDataSourceAsync(new StreamedMediaSource(stream)).ConfigureAwait(false);
+
+ // Use scaled frame for efficiency
+ using var bitmap = retriever.GetScaledFrameAtTime(captureTime.Ticks, Option.ClosestSync, 320, 240);
+ if (bitmap is null)
+ throw new NotSupportedException("Could not retrieve scaled frame.");
+
+ return await CompressBitmapAsync(bitmap).ConfigureAwait(false);
+ }
+
private static int CalculateInSampleSize(int width, int height, int reqSize)
{
var inSampleSize = 1;
@@ -108,10 +136,8 @@ private static Bitmap ApplyExifOrientation(Bitmap bitmap, ExifInterface exif)
return rotated;
}
- public static async Task CompressBitmapAsync(Bitmap bitmap)
+ private static async Task CompressBitmapAsync(Bitmap bitmap)
{
- const int IMAGE_THUMBNAIL_QUALITY = 70;
-
var memoryStream = new MemoryStream();
await bitmap.CompressAsync(Bitmap.CompressFormat.Jpeg!, IMAGE_THUMBNAIL_QUALITY, memoryStream).ConfigureAwait(false);
memoryStream.Position = 0L;
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj b/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj
index dfc1bf271..e28495492 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj
+++ b/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj
@@ -20,10 +20,4 @@
-
-
- false
-
-
-
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs
index 14bc7d122..357c26a1a 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs
@@ -1,4 +1,3 @@
-using Android.Media;
using OwlCore.Storage;
using SecureFolderFS.Core.MobileFS.Platforms.Android.Helpers;
using SecureFolderFS.Maui.AppModels;
@@ -8,7 +7,6 @@
using SecureFolderFS.Shared.Enums;
using SecureFolderFS.Shared.Helpers;
using SecureFolderFS.UI;
-using Stream = System.IO.Stream;
namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation
{
@@ -34,7 +32,7 @@ public override async Task GenerateThumbnailAsync(IFile file, Type
case TypeHint.Media:
{
await using var stream = await file.OpenReadAsync(cancellationToken).ConfigureAwait(false);
- var imageStream = await GenerateVideoThumbnailAsync(stream, TimeSpan.FromSeconds(0)).ConfigureAwait(false);
+ var imageStream = await ThumbnailHelpers.GenerateVideoThumbnailAsync(stream, TimeSpan.FromSeconds(0)).ConfigureAwait(false);
return new ImageStreamSource(imageStream);
}
@@ -42,18 +40,5 @@ public override async Task GenerateThumbnailAsync(IFile file, Type
default: throw new InvalidOperationException("The provided file type is invalid.");
}
}
-
- private static async Task GenerateVideoThumbnailAsync(Stream stream, TimeSpan captureTime)
- {
- using var retriever = new MediaMetadataRetriever();
- await retriever.SetDataSourceAsync(new StreamedMediaSource(stream)).ConfigureAwait(false);
-
- // Use scaled frame for efficiency
- using var bitmap = retriever.GetScaledFrameAtTime(captureTime.Ticks, Option.ClosestSync, 320, 240);
- if (bitmap is null)
- throw new NotSupportedException("Could not retrieve scaled frame.");
-
- return await ThumbnailHelpers.CompressBitmapAsync(bitmap).ConfigureAwait(false);
- }
}
}
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs
index f4f66ffaa..10e8503fa 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs
@@ -1,4 +1,3 @@
-using System.Diagnostics;
using Android.Content;
using AndroidX.DocumentFile.Provider;
using OwlCore.Storage;
@@ -108,7 +107,6 @@ public Task RemoveBookmarkAsync(CancellationToken cancellationToken = default)
catch (Exception ex)
{
_ = ex;
- Debugger.Break();
}
return null;
From 3011661befc14134b0d587c13f1e2ec9852cff26 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 05:29:10 +0200
Subject: [PATCH 18/55] Improved DocumentsProvider implementation
---
.../FileSystem/FileSystemProvider.Helpers.cs | 83 +++++++---
.../FileSystem/FileSystemProvider.Main.cs | 153 +++++++++++++-----
.../Android/FileSystem/Projections.cs | 1 +
.../Android/FileSystem/ReadWriteCallbacks.cs | 32 ++--
.../Android/FileSystem/RootCollection.cs | 44 ++---
.../Android/Helpers/ThumbnailHelpers.cs | 6 +-
6 files changed, 225 insertions(+), 94 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs
index cdb2660fe..49ed950a2 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs
@@ -1,4 +1,4 @@
-using Android.Database;
+using Android.Database;
using Android.Provider;
using Android.Webkit;
using OwlCore.Storage;
@@ -18,30 +18,28 @@ private bool AddRoot(MatrixCursor matrix, SafRoot safRoot, int iconRid)
if (row is null)
return false;
- var rootFolderId = GetDocumentIdForStorable(safRoot.StorageRoot.PlaintextRoot, safRoot.RootId);
+ var rootFolderId = BuildDocumentId(safRoot, safRoot.StorageRoot.PlaintextRoot);
row.Add(Root.ColumnRootId, safRoot.RootId);
row.Add(Root.ColumnDocumentId, rootFolderId);
row.Add(Root.ColumnTitle, safRoot.StorageRoot.Options.VolumeName);
row.Add(Root.ColumnIcon, iconRid);
- row.Add(Root.ColumnFlags, (int)(DocumentRootFlags.LocalOnly | DocumentRootFlags.SupportsCreate));
+
+ // SupportsIsChild is required for ACTION_OPEN_DOCUMENT_TREE to work against this root
+ row.Add(Root.ColumnFlags, (int)(DocumentRootFlags.LocalOnly | DocumentRootFlags.SupportsCreate | DocumentRootFlags.SupportsIsChild));
return true;
}
- private async Task AddDocumentAsync(MatrixCursor matrix, IStorable storable, string? documentId)
+ private async Task AddDocumentAsync(MatrixCursor matrix, IStorable storable, SafRoot safRoot, string documentId)
{
- documentId ??= GetDocumentIdForStorable(storable, null);
var row = matrix.NewRow();
if (row is null)
return false;
- var safRoot = _rootCollection?.GetSafRootForStorable(storable);
- if (safRoot is null)
- return false;
-
AddDocumentId();
AddDisplayName();
await AddSizeAsync();
+ await AddLastModifiedAsync();
AddMimeType();
AddFlags();
@@ -58,6 +56,15 @@ async Task AddSizeAsync()
var size = await file.GetSizeAsync();
row.Add(Document.ColumnSize, size);
}
+ async Task AddLastModifiedAsync()
+ {
+ if (storable is not ILastModifiedAt lastModifiedAt)
+ return;
+
+ var dateModified = await SafetyHelpers.NoFailureAsync(() => lastModifiedAt.LastModifiedAt.GetValueAsync());
+ if (dateModified is not null)
+ row.Add(Document.ColumnLastModified, new DateTimeOffset(dateModified.Value.ToUniversalTime()).ToUnixTimeMilliseconds());
+ }
void AddFlags()
{
var baseFlags = (DocumentContractFlags)0;
@@ -95,20 +102,47 @@ void AddDisplayName() => row.Add(Document.ColumnDisplayName, string.IsNullOrEmpt
: storable.Name);
}
- private string? GetDocumentIdForStorable(IStorable storable, string? rootId)
+ ///
+ /// Builds the document ID (in 'rootId:plaintextPath' format) of under .
+ ///
+ private static string BuildDocumentId(SafRoot safRoot, IStorable storable)
{
- var safRoot = rootId is not null
- ? _rootCollection?.GetSafRootForRootId(rootId)
- : _rootCollection?.GetSafRootForStorable(storable);
-
- if (safRoot is null)
- return null;
-
return storable.Id == safRoot.StorageRoot.PlaintextRoot.Id
? $"{safRoot.RootId}:"
: $"{safRoot.RootId}:{storable.Id}";
}
+ ///
+ /// Gets the document ID of the parent of the item identified by .
+ ///
+ private static string? GetParentDocumentId(string documentId)
+ {
+ var split = documentId.Split(':', 2);
+ if (split.Length < 2)
+ return null;
+
+ var path = split[1].TrimEnd('/');
+ var separatorIndex = path.LastIndexOf('/');
+
+ return separatorIndex <= 0 ? $"{split[0]}:" : $"{split[0]}:{path[..separatorIndex]}";
+ }
+
+ ///
+ /// Resolves the that the document identified by belongs to.
+ ///
+ ///
+ /// The root is always derived from the document ID. It must never be inferred from a storable's
+ /// path, because plaintext paths are not unique across roots (every root starts at '/').
+ ///
+ private SafRoot? GetSafRootForDocumentId(string documentId)
+ {
+ var split = documentId.Split(':', 2);
+ if (split.Length < 2)
+ return null;
+
+ return _rootCollection?.GetSafRootForRootId(split[0]);
+ }
+
private IStorable? GetStorableForDocumentId(string documentId)
{
if (_rootCollection is null)
@@ -137,6 +171,19 @@ void AddDisplayName() => row.Add(Document.ColumnDisplayName, string.IsNullOrEmpt
return SafetyHelpers.NoFailureResult(() => safRoot.StorageRoot.PlaintextRoot.GetItemByRelativePathAsync(path).ConfigureAwait(false).GetAwaiter().GetResult());
}
+ ///
+ /// Notifies content observers that the children of have changed.
+ ///
+ private void NotifyChildDocumentsChange(string? parentDocumentId)
+ {
+ if (parentDocumentId is null)
+ return;
+
+ var childrenUri = BuildChildDocumentsUri(Constants.Android.FileSystem.AUTHORITY, parentDocumentId);
+ if (childrenUri is not null)
+ Context?.ContentResolver?.NotifyChange(childrenUri, null);
+ }
+
private static string GetMimeForStorable(IStorable storable)
{
if (storable is IFolder)
@@ -148,7 +195,7 @@ private static string GetMimeForStorable(IStorable storable)
return "application/octet-stream";
// Remove the starting dot
- return MimeTypeMap.Singleton?.GetMimeTypeFromExtension(extension.Substring(1)) ?? string.Empty;
+ return MimeTypeMap.Singleton?.GetMimeTypeFromExtension(extension.Substring(1).ToLowerInvariant()) ?? "application/octet-stream";
}
}
}
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs
index f1a6682aa..d9e49f662 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs
@@ -1,4 +1,4 @@
-using Android.App;
+using Android.App;
using Android.Content;
using Android.Content.Res;
using Android.Database;
@@ -8,7 +8,6 @@
using Microsoft.Maui.Platform;
using OwlCore.Storage;
using SecureFolderFS.Core.MobileFS.Platforms.Android.Helpers;
-using SecureFolderFS.Shared.ComponentModel;
using SecureFolderFS.Shared.Enums;
using SecureFolderFS.Shared.Extensions;
using SecureFolderFS.Shared.Helpers;
@@ -16,7 +15,6 @@
using SecureFolderFS.Storage.Renamable;
using static SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem.Projections;
using Point = Android.Graphics.Point;
-using Uri = Android.Net.Uri;
namespace SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem
{
@@ -58,6 +56,36 @@ public override bool OnCreate()
return matrix;
}
+ ///
+ public override bool IsChildDocument(string? parentDocumentId, string? documentId)
+ {
+ // Required for ACTION_OPEN_DOCUMENT_TREE - the system validates every access
+ // through a tree grant by checking the ancestry of the target document
+ parentDocumentId = parentDocumentId == "null" ? null : parentDocumentId;
+ documentId = documentId == "null" ? null : documentId;
+ if (parentDocumentId is null || documentId is null)
+ return false;
+
+ var parentSplit = parentDocumentId.Split(':', 2);
+ var childSplit = documentId.Split(':', 2);
+ if (parentSplit.Length < 2 || childSplit.Length < 2)
+ return false;
+
+ // Both documents must belong to the same root
+ if (parentSplit[0] != childSplit[0])
+ return false;
+
+ var parentPath = parentSplit[1].TrimEnd('/');
+ var childPath = childSplit[1];
+
+ // The root folder is an ancestor of every document within it
+ if (parentPath.Length == 0)
+ return true;
+
+ return childPath.StartsWith(parentPath, StringComparison.Ordinal)
+ && (childPath.Length == parentPath.Length || childPath[parentPath.Length] == '/');
+ }
+
///
public override string? CreateDocument(string? parentDocumentId, string? mimeType, string? displayName)
{
@@ -69,19 +97,28 @@ public override bool OnCreate()
if (parentStorable is not IModifiableFolder parentFolder)
return null;
- var safRoot = _rootCollection?.GetSafRootForStorable(parentStorable);
+ var safRoot = GetSafRootForDocumentId(parentDocumentId);
if (safRoot is null)
return null;
if (safRoot.StorageRoot.Options.IsReadOnly)
return null;
+ // De-duplicate the display name, as creating over an existing document
+ // would return the existing item and its contents would get overwritten
+ var finalName = displayName;
+ var counter = 1;
+ while (counter < 32 && parentFolder.TryGetFirstByNameAsync(finalName).ConfigureAwait(false).GetAwaiter().GetResult() is not null)
+ finalName = $"{Path.GetFileNameWithoutExtension(displayName)} ({counter++}){Path.GetExtension(displayName)}";
+
var createdItem = (IStorableChild)(mimeType switch
{
- DocumentsContract.Document.MimeTypeDir => parentFolder.CreateFolderAsync(displayName).ConfigureAwait(false).GetAwaiter().GetResult(),
- _ => parentFolder.CreateFileAsync(displayName).ConfigureAwait(false).GetAwaiter().GetResult()
+ DocumentsContract.Document.MimeTypeDir => parentFolder.CreateFolderAsync(finalName).ConfigureAwait(false).GetAwaiter().GetResult(),
+ _ => parentFolder.CreateFileAsync(finalName).ConfigureAwait(false).GetAwaiter().GetResult()
});
+ NotifyChildDocumentsChange(parentDocumentId);
+
var rootId = parentDocumentId.Split(':', 2)[0];
return $"{rootId}:{createdItem.Id}";
}
@@ -95,11 +132,11 @@ public override bool OnCreate()
var file = GetStorableForDocumentId(documentId);
if (file is not IChildFile childFile)
- return null;
+ throw new Java.IO.FileNotFoundException($"No document found for '{documentId}'.");
- var safRoot = _rootCollection?.GetSafRootForStorable(file);
+ var safRoot = GetSafRootForDocumentId(documentId);
if (safRoot is null)
- return null;
+ throw new Java.IO.FileNotFoundException($"No root found for '{documentId}'.");
var fileAccess = ToFileAccess(mode);
if (safRoot.StorageRoot.Options.IsReadOnly && fileAccess.HasFlag(FileAccess.Write))
@@ -109,24 +146,30 @@ public override bool OnCreate()
if (stream is null)
return null;
- var parcelFileMode = ToParcelFileMode(mode);
- if (safRoot.StorageRoot.Options.IsReadOnly && parcelFileMode is ParcelFileMode.WriteOnly or ParcelFileMode.ReadWrite)
- return null;
+ // The 'w' family of modes replaces the existing content
+ if (IsTruncateMode(mode))
+ stream.SetLength(0L);
var handlerThread = new HandlerThread("ProxyFD-" + documentId);
handlerThread.Start();
return _storageManager.OpenProxyFileDescriptor(
- parcelFileMode,
+ ToParcelFileMode(mode),
new ReadWriteCallbacks(stream, handlerThread),
new Handler(handlerThread.Looper!));
+ static bool IsTruncateMode(string? fileMode)
+ {
+ return fileMode is "w" or "wt" or "rwt";
+ }
+
static ParcelFileMode ToParcelFileMode(string? fileMode)
{
return fileMode switch
{
"r" => ParcelFileMode.ReadOnly,
- "w" => ParcelFileMode.WriteOnly,
- "rw" => ParcelFileMode.ReadWrite,
+ "w" or "wt" => ParcelFileMode.WriteOnly,
+ "wa" => ParcelFileMode.WriteOnly | ParcelFileMode.Append,
+ "rw" or "rwt" => ParcelFileMode.ReadWrite,
_ => throw new ArgumentException($"Unsupported mode: {fileMode}.")
};
}
@@ -136,8 +179,9 @@ static FileAccess ToFileAccess(string? fileMode)
return fileMode switch
{
"r" => FileAccess.Read,
- "w" => FileAccess.Write,
- "rw" => FileAccess.ReadWrite,
+ // Writes require read access as well (encrypted chunks are read-modify-write)
+ "w" or "wt" or "wa" => FileAccess.ReadWrite,
+ "rw" or "rwt" => FileAccess.ReadWrite,
_ => throw new ArgumentException($"Unsupported mode: {fileMode}.")
};
}
@@ -152,10 +196,11 @@ static FileAccess ToFileAccess(string? fileMode)
return matrix;
var storable = GetStorableForDocumentId(documentId);
- if (storable is null)
- return matrix;
+ var safRoot = GetSafRootForDocumentId(documentId);
+ if (storable is null || safRoot is null)
+ throw new Java.IO.FileNotFoundException($"No document found for '{documentId}'.");
- AddDocumentAsync(matrix, storable, documentId).ConfigureAwait(false).GetAwaiter().GetResult();
+ AddDocumentAsync(matrix, storable, safRoot, documentId).ConfigureAwait(false).GetAwaiter().GetResult();
return matrix;
}
@@ -168,15 +213,24 @@ static FileAccess ToFileAccess(string? fileMode)
return matrix;
var parent = GetStorableForDocumentId(parentDocumentId);
- if (parent is not IFolder folder)
- return matrix;
+ var safRoot = GetSafRootForDocumentId(parentDocumentId);
+ if (parent is not IFolder folder || safRoot is null)
+ throw new Java.IO.FileNotFoundException($"No document found for '{parentDocumentId}'.");
var items = folder.GetItemsAsync().ToArrayAsyncImpl().ConfigureAwait(false).GetAwaiter().GetResult();
foreach (var item in items)
{
- AddDocumentAsync(matrix, item, null).ConfigureAwait(false).GetAwaiter().GetResult();
+ // Build the child ID from the parent's root. Inferring the root from
+ // the item is ambiguous when multiple vaults are unlocked
+ var childDocumentId = BuildDocumentId(safRoot, item);
+ AddDocumentAsync(matrix, item, safRoot, childDocumentId).ConfigureAwait(false).GetAwaiter().GetResult();
}
+ // Register for change notifications so file managers refresh automatically
+ var childrenUri = DocumentsContract.BuildChildDocumentsUri(Constants.Android.FileSystem.AUTHORITY, parentDocumentId);
+ if (childrenUri is not null && Context?.ContentResolver is { } contentResolver)
+ matrix.SetNotificationUri(contentResolver, childrenUri);
+
return matrix;
}
@@ -195,9 +249,9 @@ public override void DeleteDocument(string? documentId)
var storable = GetStorableForDocumentId(documentId);
if (storable is not IStorableChild storableChild)
- return;
+ throw new Java.IO.FileNotFoundException($"No document found for '{documentId}'.");
- var safRoot = _rootCollection?.GetSafRootForStorable(storable);
+ var safRoot = GetSafRootForDocumentId(documentId);
if (safRoot is null)
return;
@@ -213,6 +267,7 @@ public override void DeleteDocument(string? documentId)
// Perform deletion
modifiableFolder.DeleteAsync(storableChild).ConfigureAwait(false).GetAwaiter().GetResult();
+ NotifyChildDocumentsChange(GetParentDocumentId(documentId));
}
///
@@ -228,7 +283,7 @@ public override void DeleteDocument(string? documentId)
if (destinationStorable is not IModifiableFolder destinationFolder)
return null;
- var safRoot = _rootCollection?.GetSafRootForStorable(destinationStorable);
+ var safRoot = GetSafRootForDocumentId(targetParentDocumentId);
if (safRoot is null)
return null;
@@ -245,13 +300,19 @@ public override void DeleteDocument(string? documentId)
case IChildFile file:
{
var movedFile = destinationFolder.MoveFileImmediatelyFrom(file, sourceParentFolder, false, CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult();
- return Path.Combine(targetParentDocumentId, movedFile.Name);
+ NotifyChildDocumentsChange(sourceParentDocumentId);
+ NotifyChildDocumentsChange(targetParentDocumentId);
+
+ return BuildDocumentId(safRoot, movedFile);
}
case IModifiableFolder folder:
{
var movedFolder = destinationFolder.MoveFromAsync(folder, sourceParentFolder, false, CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult();
- return Path.Combine(targetParentDocumentId, movedFolder.Name);
+ NotifyChildDocumentsChange(sourceParentDocumentId);
+ NotifyChildDocumentsChange(targetParentDocumentId);
+
+ return BuildDocumentId(safRoot, movedFolder);
}
default: return null;
@@ -270,9 +331,9 @@ public override void DeleteDocument(string? documentId)
var storable = GetStorableForDocumentId(documentId);
if (storable is not IStorableChild storableChild)
- return null;
+ throw new Java.IO.FileNotFoundException($"No document found for '{documentId}'.");
- var safRoot = _rootCollection?.GetSafRootForStorable(storable);
+ var safRoot = GetSafRootForDocumentId(documentId);
if (safRoot is null)
return null;
@@ -284,13 +345,10 @@ public override void DeleteDocument(string? documentId)
return null;
var renamedItem = renamableFolder.RenameAsync(storableChild, displayName).ConfigureAwait(false).GetAwaiter().GetResult();
- if (renamedItem is IWrapper { Inner: IWrapper fileUriWrapper })
- return fileUriWrapper.Inner.ToString();
-
- if (renamedItem is IWrapper { Inner: IWrapper folderUriWrapper })
- return folderUriWrapper.Inner.ToString();
+ NotifyChildDocumentsChange(GetParentDocumentId(documentId));
- throw new InvalidOperationException($"{nameof(renamedItem)} does not implement {nameof(IWrapper)}.");
+ // The contract expects the new document ID (never an underlying URI)
+ return BuildDocumentId(safRoot, renamedItem);
}
///
@@ -305,7 +363,7 @@ public override void DeleteDocument(string? documentId)
if (destinationStorable is not IModifiableFolder destinationFolder)
return null;
- var safRoot = _rootCollection?.GetSafRootForStorable(destinationStorable);
+ var safRoot = GetSafRootForDocumentId(targetParentDocumentId);
if (safRoot is null)
return null;
@@ -318,13 +376,17 @@ public override void DeleteDocument(string? documentId)
case IFile file:
{
var copiedFile = destinationFolder.CreateCopyOfAsync(file, false).ConfigureAwait(false).GetAwaiter().GetResult();
- return Path.Combine(targetParentDocumentId, copiedFile.Name);
+ NotifyChildDocumentsChange(targetParentDocumentId);
+
+ return BuildDocumentId(safRoot, copiedFile);
}
case IFolder folder:
{
var copiedFolder = destinationFolder.CreateCopyOfAsync(folder, false).ConfigureAwait(false).GetAwaiter().GetResult();
- return Path.Combine(targetParentDocumentId, copiedFolder.Name);
+ NotifyChildDocumentsChange(targetParentDocumentId);
+
+ return BuildDocumentId(safRoot, copiedFolder);
}
default: return null;
@@ -348,13 +410,24 @@ public override void DeleteDocument(string? documentId)
try
{
+ if (signal?.IsCanceled ?? false)
+ return null;
+
// Honor sizeHint from the caller instead of always using a fixed size
var size = sizeHint is not null
? (uint)Math.Max(sizeHint.X, sizeHint.Y)
: 300U;
using var inputStream = file.OpenReadAsync().ConfigureAwait(false).GetAwaiter().GetResult();
- var thumbnailStream = ThumbnailHelpers.GenerateImageThumbnailAsync(inputStream, size).ConfigureAwait(false).GetAwaiter().GetResult();
+ var thumbnailStream = typeHint is TypeHint.Media
+ ? ThumbnailHelpers.GenerateVideoThumbnailAsync(inputStream, TimeSpan.FromSeconds(0), (int)size, (int)size).ConfigureAwait(false).GetAwaiter().GetResult()
+ : ThumbnailHelpers.GenerateImageThumbnailAsync(inputStream, size).ConfigureAwait(false).GetAwaiter().GetResult();
+
+ if (signal?.IsCanceled ?? false)
+ {
+ thumbnailStream.Dispose();
+ return null;
+ }
var twoWayPipe = ParcelFileDescriptor.CreatePipe();
if (twoWayPipe is null)
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/Projections.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/Projections.cs
index a72ccd5f5..cb0dd2c75 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/Projections.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/Projections.cs
@@ -19,6 +19,7 @@ internal static class Projections
Document.ColumnDisplayName,
Document.ColumnMimeType,
Document.ColumnSize,
+ Document.ColumnLastModified,
Document.ColumnFlags
];
}
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs
index d54b63028..ccc32b923 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs
@@ -1,4 +1,5 @@
using Android.OS;
+using Android.Systems;
namespace SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem
{
@@ -16,7 +17,14 @@ public ReadWriteCallbacks(Stream stream, HandlerThread? handlerThread = null)
///
public override long OnGetSize()
{
- return _stream.Length;
+ try
+ {
+ return _stream.Length;
+ }
+ catch (Exception)
+ {
+ throw new ErrnoException(nameof(OnGetSize), OsConstants.Eio);
+ }
}
///
@@ -35,9 +43,8 @@ public override int OnRead(long offset, int size, byte[]? data)
}
catch (Exception)
{
- // TODO: Implement more exception handlers
- return 0;
- //throw new ErrnoException(nameof(OnRead), OsConstants.Eio);
+ // Returning 0 would signal EOF and silently truncate the read
+ throw new ErrnoException(nameof(OnRead), OsConstants.Eio);
}
}
@@ -57,12 +64,10 @@ public override int OnWrite(long offset, int size, byte[]? data)
return size;
}
- catch (Exception ex)
+ catch (Exception)
{
- // TODO: Implement more exception handlers
- _ = ex;
- return 0;
- //throw new ErrnoException(nameof(OnRead), OsConstants.Eio);
+ // Returning 0 would signal a successful zero-byte write and silently lose data
+ throw new ErrnoException(nameof(OnWrite), OsConstants.Eio);
}
}
@@ -71,13 +76,12 @@ public override void OnFsync()
{
try
{
- _stream.Flush();
+ if (_stream.CanWrite)
+ _stream.Flush();
}
- catch (Exception ex)
+ catch (Exception)
{
- _ = ex;
- // TODO: Implement more exception handlers
- //throw new ErrnoException(nameof(OnRead), OsConstants.Eio);
+ throw new ErrnoException(nameof(OnFsync), OsConstants.Eio);
}
}
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs
index 492d6e7f9..943ae033d 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs
@@ -1,6 +1,5 @@
using Android.Content;
using Android.Provider;
-using OwlCore.Storage;
using SecureFolderFS.Shared.Extensions;
using SecureFolderFS.Storage.VirtualFileSystem;
using System.Collections.Specialized;
@@ -12,13 +11,26 @@ public sealed class RootCollection : IDisposable
public static RootCollection? Instance { get; private set; }
private readonly Context _context;
-
- public List Roots { get; }
+ private readonly List _roots;
+ private readonly object _rootsLock = new();
+
+ ///
+ /// The returned collection is a point-in-time snapshot. Roots are added and removed
+ /// on vault lock/unlock while provider binder threads enumerate them concurrently.
+ ///
+ public IReadOnlyList Roots
+ {
+ get
+ {
+ lock (_rootsLock)
+ return _roots.ToArray();
+ }
+ }
public RootCollection(Context context)
{
_context = context;
- Roots = new();
+ _roots = new();
Instance = this;
FileSystemManager.Instance.FileSystems.CollectionChanged += FileSystemManager_CollectionChanged;
@@ -26,18 +38,8 @@ public RootCollection(Context context)
public SafRoot? GetSafRootForRootId(string rootId)
{
- return Roots.FirstOrDefault(x => x.RootId == rootId);
- }
-
- public SafRoot? GetSafRootForStorable(IStorable storable)
- {
- foreach (var safRoot in Roots)
- {
- if (storable.Id.StartsWith(safRoot.StorageRoot.PlaintextRoot.Id))
- return safRoot;
- }
-
- return null;
+ lock (_rootsLock)
+ return _roots.FirstOrDefault(x => x.RootId == rootId);
}
private void FileSystemManager_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
@@ -50,9 +52,10 @@ private void FileSystemManager_CollectionChanged(object? sender, NotifyCollectio
return;
// Add to available roots
- Roots.Add(new(storageRoot, Guid.NewGuid().ToString()));
- NotifySafChange();
+ lock (_rootsLock)
+ _roots.Add(new(storageRoot, Guid.NewGuid().ToString()));
+ NotifySafChange();
break;
}
@@ -62,9 +65,10 @@ private void FileSystemManager_CollectionChanged(object? sender, NotifyCollectio
return;
// Remove from available roots
- Roots.RemoveMatch(x => x.StorageRoot == storageRoot);
- NotifySafChange();
+ lock (_rootsLock)
+ _roots.RemoveMatch(x => x.StorageRoot == storageRoot);
+ NotifySafChange();
break;
}
}
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs
index 9f5197d68..861ccf2d1 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs
@@ -64,14 +64,16 @@ public static async Task GenerateImageThumbnailAsync(Stream stream, uint
///
/// The video file stream from which the thumbnail is generated.
/// The time position in the video to capture the thumbnail frame.
+ /// The width of the thumbnail image.
+ /// The height of the thumbnail image.
/// A that represents the asynchronous operation. Value is a stream containing the compressed thumbnail image.
- public static async Task GenerateVideoThumbnailAsync(Stream stream, TimeSpan captureTime)
+ public static async Task GenerateVideoThumbnailAsync(Stream stream, TimeSpan captureTime, int width = 320, int height = 240)
{
using var retriever = new MediaMetadataRetriever();
await retriever.SetDataSourceAsync(new StreamedMediaSource(stream)).ConfigureAwait(false);
// Use scaled frame for efficiency
- using var bitmap = retriever.GetScaledFrameAtTime(captureTime.Ticks, Option.ClosestSync, 320, 240);
+ using var bitmap = retriever.GetScaledFrameAtTime(captureTime.Ticks, Option.ClosestSync, width, height);
if (bitmap is null)
throw new NotSupportedException("Could not retrieve scaled frame.");
From a0ae6471baff296114ed3129008ef7cb4076e097 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 05:34:29 +0200
Subject: [PATCH 19/55] Added optional name parameter to AndroidFile
---
.../Platforms/Android/Storage/AndroidFile.cs | 23 ++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs
index 5df054c6e..c8a1645cb 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs
@@ -6,6 +6,7 @@
using OwlCore.Storage;
using SecureFolderFS.Core.MobileFS.Platforms.Android.Streams;
using SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties;
+using SecureFolderFS.Shared.Helpers;
using SecureFolderFS.Storage.StorageProperties;
using AndroidUri = Android.Net.Uri;
@@ -26,11 +27,11 @@ internal sealed class AndroidFile : AndroidStorable, IChildFile, ILastModifiedAt
///
public ISizeOfProperty SizeOf => field ??= new AndroidSizeOfProperty(Id, Document ?? throw new ArgumentNullException(nameof(Document)));
- public AndroidFile(AndroidUri uri, Activity activity, AndroidFolder? parent = null, AndroidUri? permissionRoot = null, string? bookmarkId = null)
+ public AndroidFile(AndroidUri uri, Activity activity, AndroidFolder? parent = null, AndroidUri? permissionRoot = null, string? bookmarkId = null, string? name = null)
: base(uri, activity, parent, permissionRoot, bookmarkId)
{
Document = DocumentFile.FromSingleUri(activity, uri);
- Name = Document?.Name ?? base.Name;
+ Name = name ?? Document?.Name ?? base.Name;
}
///
@@ -47,6 +48,20 @@ public Task OpenStreamAsync(FileAccess accessMode, CancellationToken can
if (accessMode == FileAccess.Read)
{
+ // Prefer a file-descriptor-backed stream which is seekable - random access
+ // is required both by the crypto layer and by consumers seeking within files
+ var readFd = SafetyHelpers.NoFailureResult(() => activity.ContentResolver?.OpenFileDescriptor(Inner, "r"));
+ if (readFd is not null)
+ {
+ var readChannel = new FileInputStream(readFd.FileDescriptor).Channel;
+ if (readChannel is not null)
+ return Task.FromResult(new ChannelledStream(readChannel, null, readFd));
+
+ readFd.Close();
+ }
+
+ // Fall back to a plain (forward-only) input stream for providers
+ // that cannot supply a seekable file descriptor
var inStream = activity.ContentResolver?.OpenInputStream(Inner);
if (inStream is null)
return Task.FromException(new UnauthorizedAccessException("Could not open input stream."));
@@ -55,7 +70,9 @@ public Task OpenStreamAsync(FileAccess accessMode, CancellationToken can
}
else
{
- var fd = activity.ContentResolver?.OpenFileDescriptor(Inner, "rwt");
+ // Open in "rw" mode - "rwt" would truncate the existing content on open.
+ // Truncation is a deliberate operation performed by the caller, never a side effect
+ var fd = activity.ContentResolver?.OpenFileDescriptor(Inner, "rw");
if (fd is null)
return Task.FromException(new UnauthorizedAccessException("Could not open file descriptor."));
From 29bd13c878dcb1b02595cffc64b74f69f4df151e Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 05:41:10 +0200
Subject: [PATCH 20/55] Improved thumbnail handling on Android
---
.../Android/AppModels/StreamedMediaSource.cs | 36 ++++++++++++++---
.../FileSystem/FileSystemProvider.Main.cs | 4 +-
.../Android/Helpers/ThumbnailHelpers.cs | 40 ++++++++++++++-----
.../AndroidMediaService.cs | 4 +-
4 files changed, 68 insertions(+), 16 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AppModels/StreamedMediaSource.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AppModels/StreamedMediaSource.cs
index 7710606cc..76423b3b9 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AppModels/StreamedMediaSource.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AppModels/StreamedMediaSource.cs
@@ -9,7 +9,21 @@ internal sealed class StreamedMediaSource : MediaDataSource
private readonly Stream _sourceStream;
///
- public override long Size => _sourceStream.Length;
+ public override long Size
+ {
+ get
+ {
+ try
+ {
+ // The contract specifies -1 when the size is unknown
+ return _sourceStream.CanSeek ? _sourceStream.Length : -1L;
+ }
+ catch (Exception)
+ {
+ return -1L;
+ }
+ }
+ }
public StreamedMediaSource(Stream sourceStream)
{
@@ -19,11 +33,23 @@ public StreamedMediaSource(Stream sourceStream)
///
public override int ReadAt(long position, byte[]? buffer, int offset, int size)
{
- if (buffer is null)
- return 0;
+ try
+ {
+ if (buffer is null || size <= 0)
+ return 0;
+
+ _sourceStream.Position = position;
- _sourceStream.Position = position;
- return _sourceStream.Read(buffer, offset, size);
+ // The contract specifies -1 for the end of the stream. Returning 0 would
+ // indicate that no data was read yet, causing the extractor to retry forever
+ var read = _sourceStream.Read(buffer, offset, size);
+ return read == 0 ? -1 : read;
+ }
+ catch (Exception)
+ {
+ // Errors must not propagate across the JNI boundary
+ return -1;
+ }
}
///
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs
index d9e49f662..41369fded 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs
@@ -418,9 +418,11 @@ public override void DeleteDocument(string? documentId)
? (uint)Math.Max(sizeHint.X, sizeHint.Y)
: 300U;
+ // Capture at one second rather than the very first frame, which is often
+ // black (fade-ins). ClosestSync clamps the position for shorter videos
using var inputStream = file.OpenReadAsync().ConfigureAwait(false).GetAwaiter().GetResult();
var thumbnailStream = typeHint is TypeHint.Media
- ? ThumbnailHelpers.GenerateVideoThumbnailAsync(inputStream, TimeSpan.FromSeconds(0), (int)size, (int)size).ConfigureAwait(false).GetAwaiter().GetResult()
+ ? ThumbnailHelpers.GenerateVideoThumbnailAsync(inputStream, TimeSpan.FromSeconds(1), (int)size, (int)size).ConfigureAwait(false).GetAwaiter().GetResult()
: ThumbnailHelpers.GenerateImageThumbnailAsync(inputStream, size).ConfigureAwait(false).GetAwaiter().GetResult();
if (signal?.IsCanceled ?? false)
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs
index 861ccf2d1..f9e07dc78 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs
@@ -55,8 +55,18 @@ public static async Task GenerateImageThumbnailAsync(Stream stream, uint
if (bitmap is null)
throw new Exception("Failed to decode image.");
- using var rotated = ApplyExifOrientation(bitmap, exif);
- return await CompressBitmapAsync(rotated).ConfigureAwait(false);
+ // ApplyExifOrientation returns the same instance when no transformation is
+ // needed - only dispose the result when a new bitmap was actually created
+ var oriented = ApplyExifOrientation(bitmap, exif);
+ try
+ {
+ return await CompressBitmapAsync(oriented).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (!ReferenceEquals(oriented, bitmap))
+ oriented.Dispose();
+ }
}
///
@@ -72,8 +82,12 @@ public static async Task GenerateVideoThumbnailAsync(Stream stream, Time
using var retriever = new MediaMetadataRetriever();
await retriever.SetDataSourceAsync(new StreamedMediaSource(stream)).ConfigureAwait(false);
+ // The retriever expects the timestamp in microseconds, not in ticks (100ns units).
+ // ClosestSync clamps to the nearest sync frame, so a capture time past the end is safe
+ var captureTimeUs = captureTime.Ticks / TimeSpan.TicksPerMicrosecond;
+
// Use scaled frame for efficiency
- using var bitmap = retriever.GetScaledFrameAtTime(captureTime.Ticks, Option.ClosestSync, width, height);
+ using var bitmap = retriever.GetScaledFrameAtTime(captureTimeUs, Option.ClosestSync, width, height);
if (bitmap is null)
throw new NotSupportedException("Could not retrieve scaled frame.");
@@ -89,10 +103,17 @@ private static int CalculateInSampleSize(int width, int height, int reqSize)
return inSampleSize;
}
+ ///
+ /// Applies the EXIF orientation to .
+ ///
+ ///
+ /// The caller retains ownership of . When a transformation is
+ /// applied, a new bitmap (owned by the caller) is returned; otherwise the same instance.
+ ///
private static Bitmap ApplyExifOrientation(Bitmap bitmap, ExifInterface exif)
{
var orientation = (Orientation)exif.GetAttributeInt(ExifInterface.TagOrientation, (int)Orientation.Normal);
- var matrix = new Matrix();
+ using var matrix = new Matrix();
switch (orientation)
{
@@ -133,17 +154,18 @@ private static Bitmap ApplyExifOrientation(Bitmap bitmap, ExifInterface exif)
return bitmap;
}
- var rotated = Bitmap.CreateBitmap(bitmap, 0, 0, bitmap.Width, bitmap.Height, matrix, true);
- bitmap.Dispose();
- return rotated;
+ return Bitmap.CreateBitmap(bitmap, 0, 0, bitmap.Width, bitmap.Height, matrix, true);
}
+ ///
+ /// Compresses into an image stream. The caller retains ownership of the bitmap.
+ ///
private static async Task CompressBitmapAsync(Bitmap bitmap)
{
+ var format = bitmap.HasAlpha ? Bitmap.CompressFormat.Png! : Bitmap.CompressFormat.Jpeg!;
var memoryStream = new MemoryStream();
- await bitmap.CompressAsync(Bitmap.CompressFormat.Jpeg!, IMAGE_THUMBNAIL_QUALITY, memoryStream).ConfigureAwait(false);
+ await bitmap.CompressAsync(format, IMAGE_THUMBNAIL_QUALITY, memoryStream).ConfigureAwait(false);
memoryStream.Position = 0L;
- bitmap.Dispose();
return new NonDisposableStream(memoryStream);
}
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs
index 357c26a1a..2bafd668d 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs
@@ -31,8 +31,10 @@ public override async Task GenerateThumbnailAsync(IFile file, Type
case TypeHint.Media:
{
+ // Capture at one second rather than the very first frame, which is often
+ // black (fade-ins). ClosestSync clamps the position for shorter videos
await using var stream = await file.OpenReadAsync(cancellationToken).ConfigureAwait(false);
- var imageStream = await ThumbnailHelpers.GenerateVideoThumbnailAsync(stream, TimeSpan.FromSeconds(0)).ConfigureAwait(false);
+ var imageStream = await ThumbnailHelpers.GenerateVideoThumbnailAsync(stream, TimeSpan.FromSeconds(1)).ConfigureAwait(false);
return new ImageStreamSource(imageStream);
}
From aae395d5d008962a97d4ec03a8433ca8e3ab502d Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 05:48:57 +0200
Subject: [PATCH 21/55] Improved the performance of AndroidFolder
---
.../Android/Storage/AndroidFolder.cs | 211 ++++++++++++++----
1 file changed, 164 insertions(+), 47 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs
index 69ae27829..9a48ef3de 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs
@@ -5,6 +5,7 @@
using OwlCore.Storage;
using SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties;
using SecureFolderFS.Shared.Extensions;
+using SecureFolderFS.Shared.Helpers;
using SecureFolderFS.Storage.Renamable;
using Activity = Android.App.Activity;
using AndroidUri = Android.Net.Uri;
@@ -31,11 +32,11 @@ internal sealed class AndroidFolder : AndroidStorable,
///
public ILastModifiedAtProperty LastModifiedAt => field ??= new AndroidLastModifiedAtProperty(Id, Document ?? throw new ArgumentNullException(nameof(Document)));
- public AndroidFolder(AndroidUri uri, Activity activity, AndroidFolder? parent = null, AndroidUri? permissionRoot = null, string? bookmarkId = null)
+ public AndroidFolder(AndroidUri uri, Activity activity, AndroidFolder? parent = null, AndroidUri? permissionRoot = null, string? bookmarkId = null, string? name = null)
: base(uri, activity, parent, permissionRoot, bookmarkId)
{
Document = DocumentFile.FromTreeUri(activity, uri);
- Name = Document?.Name ?? base.Name;
+ Name = name ?? Document?.Name ?? base.Name;
}
///
@@ -52,7 +53,7 @@ public Task RenameAsync(IStorableChild storable, string newName,
if (uri is null)
return Task.FromException(RenameException);
- return Task.FromResult(new AndroidFolder(uri, activity, Parent, permissionRoot));
+ return Task.FromResult(new AndroidFolder(uri, activity, this, permissionRoot));
}
case AndroidFile file:
@@ -64,7 +65,7 @@ public Task RenameAsync(IStorableChild storable, string newName,
if (uri is null)
return Task.FromException(RenameException);
- return Task.FromResult(new AndroidFile(uri, activity, Parent, permissionRoot));
+ return Task.FromResult(new AndroidFile(uri, activity, this, permissionRoot));
}
default: return Task.FromException(new ArgumentOutOfRangeException(nameof(storable)));
@@ -74,6 +75,33 @@ public Task RenameAsync(IStorableChild storable, string newName,
///
public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
+ // Fast path: enumerate children with a single ContentResolver query.
+ // DocumentFile.ListFiles() plus per-item name lookups cost several
+ // ContentResolver round-trips per item, making large listings very slow
+ var children = TryEnumerateChildren();
+ if (children is not null)
+ {
+ foreach (var (uri, name, isDirectory) in children)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var result = (IStorableChild?)(type switch
+ {
+ StorableType.File when !isDirectory => new AndroidFile(uri, activity, this, permissionRoot, name: name),
+ StorableType.Folder when isDirectory => new AndroidFolder(uri, activity, this, permissionRoot, name: name),
+ StorableType.All => isDirectory
+ ? new AndroidFolder(uri, activity, this, permissionRoot, name: name)
+ : new AndroidFile(uri, activity, this, permissionRoot, name: name),
+ _ => null
+ });
+
+ if (result is not null)
+ yield return result;
+ }
+
+ yield break;
+ }
+
+ // Fallback: DocumentFile-based enumeration
if (Document is null)
yield break;
@@ -104,6 +132,90 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type =
await Task.CompletedTask.ConfigureAwait(false);
}
+ ///
+ /// Enumerates the children of this folder with a single ContentResolver query.
+ ///
+ /// The list of children, or null when the query is unsupported or failed.
+ private List<(AndroidUri Uri, string Name, bool IsDirectory)>? TryEnumerateChildren()
+ {
+ try
+ {
+ if (activity.ContentResolver is null)
+ return null;
+
+ var documentId = DocumentsContract.IsDocumentUri(activity, Inner)
+ ? DocumentsContract.GetDocumentId(Inner)
+ : DocumentsContract.GetTreeDocumentId(Inner);
+
+ if (documentId is null)
+ return null;
+
+ var childrenUri = DocumentsContract.BuildChildDocumentsUriUsingTree(Inner, documentId);
+ if (childrenUri is null)
+ return null;
+
+ var projection = new[]
+ {
+ DocumentsContract.Document.ColumnDocumentId,
+ DocumentsContract.Document.ColumnDisplayName,
+ DocumentsContract.Document.ColumnMimeType
+ };
+
+ using var cursor = activity.ContentResolver.Query(childrenUri, projection, null, null, null);
+ if (cursor is null)
+ return null;
+
+ var children = new List<(AndroidUri, string, bool)>(cursor.Count);
+ while (cursor.MoveToNext())
+ {
+ var childDocumentId = cursor.GetString(0);
+ var childName = cursor.GetString(1);
+ var childMimeType = cursor.GetString(2);
+ if (childDocumentId is null || childName is null)
+ continue;
+
+ var childUri = DocumentsContract.BuildDocumentUriUsingTree(Inner, childDocumentId);
+ if (childUri is null)
+ continue;
+
+ var isDirectory = childMimeType == DocumentsContract.Document.MimeTypeDir;
+ children.Add((childUri, childName, isDirectory));
+ }
+
+ return children;
+ }
+ catch (Exception)
+ {
+ // Not a tree-based document provider or the query failed
+ return null;
+ }
+ }
+
+ ///
+ /// Finds a direct child by name or null when no child with the given name exists.
+ ///
+ private (AndroidUri Uri, string Name, bool IsDirectory)? TryFindChild(string name)
+ {
+ var children = TryEnumerateChildren();
+ if (children is not null)
+ {
+ foreach (var child in children)
+ {
+ if (name.Equals(child.Name, StringComparison.Ordinal))
+ return child;
+ }
+
+ return null;
+ }
+
+ // Fallback: DocumentFile-based lookup
+ var file = Document?.FindFile(name);
+ if (file?.Uri is null)
+ return null;
+
+ return (file.Uri, file.Name ?? name, file.IsDirectory);
+ }
+
///
public Task GetFolderWatcherAsync(CancellationToken cancellationToken = default)
{
@@ -113,41 +225,47 @@ public Task GetFolderWatcherAsync(CancellationToken cancellation
///
public async Task DeleteAsync(IStorableChild item, CancellationToken cancellationToken = default)
{
- await DeleteContents(item);
+ if (item is not AndroidStorable androidStorable)
+ throw new ArgumentException($"The {nameof(item)} is not an Android storable.", nameof(item));
+
+ if (activity.ContentResolver is null)
+ throw new UnauthorizedAccessException("Could not access Android content resolver.");
+
+ // Fast path: DeleteDocument removes documents (including whole directory
+ // trees on most providers) with a single call
+ var deleted = SafetyHelpers.NoFailureResult(() => DocumentsContract.DeleteDocument(activity.ContentResolver, androidStorable.Inner));
+ if (deleted)
+ return;
+
+ // Fallback: delete the folder contents recursively
+ if (item is not AndroidFolder folderToDelete)
+ throw new IOException($"Could not delete '{item.Name}'.");
+
+ await DeleteContents(folderToDelete);
return;
- async Task DeleteContents(IStorableChild storable)
+ async Task DeleteContents(AndroidFolder storableIsFolder)
{
- switch (storable)
+ await foreach (var itemInFolder in storableIsFolder.GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false))
{
- case AndroidFile storableIsFile:
+ switch (itemInFolder)
{
- if (activity.ContentResolver is not null)
- DocumentsContract.DeleteDocument(activity.ContentResolver, storableIsFile.Inner);
+ case AndroidFolder androidFolder:
+ await DeleteContents(androidFolder);
+ break;
- break;
- }
-
- case AndroidFolder storableIsFolder:
- {
- await foreach (var itemInFolder in storableIsFolder.GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false))
+ case AndroidFile androidFile:
{
- switch (itemInFolder)
- {
- case AndroidFolder androidFolder:
- await DeleteContents(androidFolder);
- break;
-
- case AndroidFile androidFile when activity.ContentResolver is not null:
- DocumentsContract.DeleteDocument(activity.ContentResolver, androidFile.Inner);
- break;
- }
- }
+ if (!DocumentsContract.DeleteDocument(activity.ContentResolver, androidFile.Inner))
+ throw new IOException($"Could not delete '{androidFile.Name}'.");
- DocumentFile.FromTreeUri(activity, storableIsFolder.Inner)?.Delete();
- break;
+ break;
+ }
}
}
+
+ if (DocumentFile.FromTreeUri(activity, storableIsFolder.Inner)?.Delete() != true)
+ throw new IOException($"Could not delete '{storableIsFolder.Name}'.");
}
}
@@ -165,15 +283,16 @@ public Task CreateFolderAsync(string name, bool overwrite = false,
public Task CreateFileAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default)
{
var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream";
- var existingFile = Document?.FindFile(name);
+ var existingFile = TryFindChild(name);
- if (overwrite && existingFile?.Uri is not null)
+ if (overwrite && existingFile is not null)
{
- existingFile.Delete();
+ if (activity.ContentResolver is null || !DocumentsContract.DeleteDocument(activity.ContentResolver, existingFile.Value.Uri))
+ return Task.FromException(new IOException($"Could not delete the existing file '{name}'."));
}
- else if (existingFile?.Uri is not null)
+ else if (existingFile is not null)
{
- return Task.FromResult(new AndroidFile(existingFile.Uri, activity, this, permissionRoot));
+ return Task.FromResult(new AndroidFile(existingFile.Value.Uri, activity, this, permissionRoot, name: existingFile.Value.Name));
}
var newFile = Document?.CreateFile(mimeType, name);
@@ -197,13 +316,14 @@ public Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite, stri
if (fileToCopy is not AndroidFile androidFile)
return fallback(this, fileToCopy, overwrite, newName, cancellationToken);
- var existingFile = Document?.FindFile(newName);
+ var existingFile = TryFindChild(newName);
if (existingFile is not null)
{
if (!overwrite)
return Task.FromException(new FileAlreadyExistsException(newName));
- existingFile.Delete();
+ if (activity.ContentResolver is null || !DocumentsContract.DeleteDocument(activity.ContentResolver, existingFile.Value.Uri))
+ return Task.FromException(new IOException($"Could not delete the existing file '{newName}'."));
}
// No-op if source and destination are the same
@@ -231,13 +351,14 @@ public Task MoveFromAsync(IChildFile fileToMove, IModifiableFolder s
if (androidFile.Id == Path.Combine(Id, newName))
return Task.FromResult(fileToMove);
- var existingFile = Document?.FindFile(newName);
+ var existingFile = TryFindChild(newName);
if (existingFile is not null)
{
if (!overwrite)
return Task.FromException(new FileAlreadyExistsException(newName));
- existingFile.Delete();
+ if (activity.ContentResolver is null || !DocumentsContract.DeleteDocument(activity.ContentResolver, existingFile.Value.Uri))
+ return Task.FromException(new IOException($"Could not delete the existing file '{newName}'."));
}
// Fast-path: same folder means this is a pure rename
@@ -251,16 +372,12 @@ public Task MoveFromAsync(IChildFile fileToMove, IModifiableFolder s
///
public async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken = default)
{
- var file = Document?.FindFile(name);
- if (file?.Uri is not null)
+ var child = TryFindChild(name);
+ if (child is not null)
{
- if (file.IsFile)
- return new AndroidFile(file.Uri, activity, this, permissionRoot);
-
- if (file.IsDirectory)
- return new AndroidFolder(file.Uri, activity, this, permissionRoot);
-
- throw new InvalidOperationException("The found item is neither a file nor a folder.");
+ return child.Value.IsDirectory
+ ? new AndroidFolder(child.Value.Uri, activity, this, permissionRoot, name: child.Value.Name)
+ : new AndroidFile(child.Value.Uri, activity, this, permissionRoot, name: child.Value.Name);
}
var target = await GetItemsAsync(cancellationToken: cancellationToken)
From 56e7a3b5440c09faf5a688ef33ca14a77a518436 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 05:53:24 +0200
Subject: [PATCH 22/55] Return a snapshot of handles and enforce ReadWrite in
FUSE
---
.../OpenHandles/FuseHandlesManager.cs | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs b/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs
index 6b60addc9..bb7f32cb3 100644
--- a/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs
+++ b/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs
@@ -13,12 +13,22 @@ public FuseHandlesManager(StreamsAccess streamsAccess, VirtualFileSystemOptions
{
}
+ ///
+ /// Provides an enumerable collection of all currently active handles managed by the instance.
+ ///
+ ///
+ /// The handles are returned as a snapshot of the internal collection to prevent race conditions
+ /// when accessing or enumerating the data. Any modifications to the collection are thread-safe
+ /// and do not affect the snapshot returned by this property.
+ ///
public IEnumerable OpenHandles
{
get
{
+ // Return a snapshot - the live collection could be mutated
+ // by another thread while the caller is enumerating it
lock (handles)
- return handles.Values;
+ return handles.Values.ToArray();
}
}
@@ -38,7 +48,11 @@ public override ulong OpenFileHandle(string ciphertextPath, FileMode mode, FileA
// A file cannot be opened with both FileMode.Append and FileMode.Read, but opening it with
// FileMode.Write would cause an error when writing, as the stream needs to be readable.
Mode = mode == FileMode.Append ? FileMode.Open : mode,
- Access = FileAccess.ReadWrite,
+
+ // Writes require read access as well (chunks are read-modify-write), but read-only
+ // opens must not request write access. Otherwise, files with read-only permissions
+ // (or vaults on read-only media) could not be opened at all
+ Access = access == FileAccess.Read ? FileAccess.Read : FileAccess.ReadWrite,
Share = share | FileShare.Delete,
Options = options
});
From 386bb229328be65169c0ae94ea933859775d44e8 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 06:02:13 +0200
Subject: [PATCH 23/55] Improved reliability of FUSE
---
.../Callbacks/OnDeviceFuse.cs | 151 ++++++++++++------
1 file changed, 102 insertions(+), 49 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs
index 659bc0f7d..f09d7a998 100644
--- a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs
+++ b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs
@@ -1,11 +1,10 @@
+using System.Text;
using SecureFolderFS.Core.FileSystem;
using SecureFolderFS.Core.FileSystem.Helpers.Paths;
+using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract;
using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native;
using SecureFolderFS.Core.FUSE.OpenHandles;
using SecureFolderFS.Core.FUSE.UnsafeNative;
-using System.Runtime.CompilerServices;
-using System.Text;
-using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract;
using Tmds.Fuse;
using Tmds.Linux;
using static SecureFolderFS.Core.FUSE.UnsafeNative.UnsafeNativeApis;
@@ -83,7 +82,6 @@ public override unsafe int Create(ReadOnlySpan path, mode_t mode, ref Fuse
}
}
- [MethodImpl(MethodImplOptions.Synchronized)]
public override int FAllocate(ReadOnlySpan path, int mode, ulong offset, long length, ref FuseFileInfo fi)
{
if (FuseOptions!.IsReadOnly)
@@ -97,39 +95,53 @@ public override int FAllocate(ReadOnlySpan path, int mode, ulong offset, l
if (handle is null || !handle.FileAccess.HasFlag(FileAccess.Write))
return -EBADF;
+ // Only the default mode (extend allocation and size) is supported.
+ // FALLOC_FL_KEEP_SIZE and other flags arrive in 'mode', not in fi.flags
if (mode != 0)
return -EOPNOTSUPP;
var newLength = (long)offset + length;
- if ((fi.flags & FALLOC_FL_KEEP_SIZE) == 0 && newLength > handle.Stream.Length)
- handle.Stream.SetLength(newLength);
+ lock (handle.Stream)
+ {
+ if (newLength > handle.Stream.Length)
+ handle.Stream.SetLength(newLength);
+ }
return 0;
}
- [MethodImpl(MethodImplOptions.Synchronized)]
public override int Flush(ReadOnlySpan path, ref FuseFileInfo fi)
{
var handle = handlesManager.GetHandle(fi.fh);
- if (handle is null || !handle.FileAccess.HasFlag(FileAccess.Write))
+ if (handle is null)
return -EBADF;
- handle.Stream.Flush();
+ // Flush is invoked for every close(2), including read-only descriptors
+ if (!handle.FileAccess.HasFlag(FileAccess.Write))
+ return 0;
+
+ lock (handle.Stream)
+ handle.Stream.Flush();
+
return 0;
}
- [MethodImpl(MethodImplOptions.Synchronized)]
public override unsafe int FSync(ReadOnlySpan path, bool onlyData, ref FuseFileInfo fi)
{
var handle = handlesManager.GetHandle(fi.fh);
- if (handle is null || !handle.FileAccess.HasFlag(FileAccess.Write))
+ if (handle is null)
return -EBADF;
+ // fsync(2) is valid on read-only descriptors - there is nothing to flush
+ if (!handle.FileAccess.HasFlag(FileAccess.Write))
+ return 0;
+
var ciphertextPath = GetCiphertextPath(path);
if (ciphertextPath is null)
return -ENOENT;
- handle.Stream.Flush();
+ lock (handle.Stream)
+ handle.Stream.Flush();
if (onlyData)
return 0;
@@ -149,20 +161,20 @@ public override unsafe int FSync(ReadOnlySpan path, bool onlyData, ref Fus
}
}
- [MethodImpl(MethodImplOptions.Synchronized)]
public override unsafe int FSyncDir(ReadOnlySpan path, bool onlyData, ref FuseFileInfo fi)
{
- if (FuseOptions!.IsReadOnly)
- return -EROFS;
-
var ciphertextPath = GetCiphertextPath(path);
if (ciphertextPath is null)
return -ENOENT;
+ // Flush writable handles of files located inside the directory being synced
foreach (var handle in handlesManager.OpenHandles)
{
- if (handle is FuseFileHandle fuseFileHandle && Path.GetDirectoryName(ciphertextPath)!.StartsWith(fuseFileHandle.Directory))
- fuseFileHandle.Stream.Flush();
+ if (handle is FuseFileHandle { Stream.CanWrite: true } fuseFileHandle && fuseFileHandle.Directory.StartsWith(ciphertextPath, StringComparison.Ordinal))
+ {
+ lock (fuseFileHandle.Stream)
+ fuseFileHandle.Stream.Flush();
+ }
}
if (onlyData)
@@ -198,8 +210,17 @@ public override unsafe int GetAttr(ReadOnlySpan path, ref stat stat, FuseF
if (LibC.stat(ciphertextPathPtr, statPtr) == -1)
return -errno;
- if (File.Exists(ciphertextPath))
+ // Prefer the open handle's stream length. It may include data that
+ // has not been flushed to the ciphertext file yet
+ if (!fiRef.IsNull && handlesManager.GetHandle(fiRef.Value.fh) is { } handle)
+ {
+ lock (handle.Stream)
+ stat.st_size = handle.Stream.Length;
+ }
+ else if (File.Exists(ciphertextPath))
+ {
stat.st_size = Math.Max(0, specifics.Security.ContentCrypt.CalculatePlaintextSize(stat.st_size - specifics.Security.HeaderCrypt.HeaderCiphertextSize));
+ }
return 0;
}
@@ -292,10 +313,10 @@ public override int Open(ReadOnlySpan path, ref FuseFileInfo fi)
var mode = FileMode.Open;
if ((fi.flags & O_APPEND) != 0)
mode = FileMode.Append;
- else if ((fi.flags & O_CREAT) != 0)
- mode = FileMode.Create;
else if ((fi.flags & O_TRUNC) != 0)
- mode = FileMode.Truncate;
+ mode = (fi.flags & O_CREAT) != 0 ? FileMode.Create : FileMode.Truncate;
+ else if ((fi.flags & O_CREAT) != 0)
+ mode = FileMode.OpenOrCreate; // O_CREAT without O_TRUNC must not truncate an existing file
var options = FileOptions.None;
if ((fi.flags & O_ASYNC) != 0)
@@ -310,10 +331,19 @@ public override int Open(ReadOnlySpan path, ref FuseFileInfo fi)
if ((fi.flags & O_TMPFILE) != 0)
options |= FileOptions.DeleteOnClose;
- if (FuseOptions!.IsReadOnly && (mode == FileMode.Create || mode == FileMode.Truncate || (fi.flags & O_WRONLY) != 0 || (fi.flags & O_RDWR) != 0))
+ var wantsWrite = (fi.flags & (O_WRONLY | O_RDWR)) != 0;
+ if (FuseOptions!.IsReadOnly && (mode is FileMode.Create or FileMode.Truncate or FileMode.OpenOrCreate || wantsWrite))
return -EROFS;
- var access = mode == FileMode.Append ? FileAccess.Write : FileAccess.ReadWrite;
+ // Read-only opens must not request write access. Otherwise, files with
+ // read-only permissions (or on read-only media) cannot be opened at all
+ var access = mode switch
+ {
+ FileMode.Append => FileAccess.Write,
+ FileMode.Create or FileMode.Truncate => FileAccess.ReadWrite,
+ _ => wantsWrite ? FileAccess.ReadWrite : FileAccess.Read
+ };
+
var handle = handlesManager.OpenFileHandle(ciphertextPath, mode, access, FileShare.ReadWrite, options);
if (handle == FileSystem.Constants.INVALID_HANDLE)
return -EACCES;
@@ -337,18 +367,22 @@ public override int OpenDir(ReadOnlySpan path, ref FuseFileInfo fi)
return 0;
}
- [MethodImpl(MethodImplOptions.Synchronized)]
public override int Read(ReadOnlySpan path, ulong offset, Span buffer, ref FuseFileInfo fi)
{
var handle = handlesManager.GetHandle(fi.fh);
if (handle is null)
return -EBADF;
- if ((long)offset > handle.Stream.Length)
- return 0;
+ // Lock on the handle's stream. The kernel can issue concurrent operations
+ // on the same handle, and setting the position and reading must be atomic
+ lock (handle.Stream)
+ {
+ if ((long)offset > handle.Stream.Length)
+ return 0;
- handle.Stream.Position = (long)offset;
- return handle.Stream.Read(buffer);
+ handle.Stream.Position = (long)offset;
+ return handle.Stream.Read(buffer);
+ }
}
public override int ReadDir(ReadOnlySpan path, ulong offset, ReadDirFlags flags, DirectoryContent content, ref FuseFileInfo fi)
@@ -363,18 +397,21 @@ public override int ReadDir(ReadOnlySpan path, ulong offset, ReadDirFlags
var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security);
foreach (var entry in Directory.GetFileSystemEntries(ciphertextPath))
{
- if (PathHelpers.IsCoreName(entry))
+ var ciphertextName = Path.GetFileName(entry);
+ if (PathHelpers.IsCoreName(ciphertextName))
continue;
- var ciphertextName = Path.GetFileName(entry);
+ // Skip entries whose names cannot be decrypted
var plaintextName = NativePathHelpers.DecryptName(ciphertextName, ciphertextPath, specifics, directoryId);
+ if (string.IsNullOrEmpty(plaintextName))
+ continue;
+
content.AddEntry(plaintextName);
}
return 0;
}
- [MethodImpl(MethodImplOptions.Synchronized)]
public override void Release(ReadOnlySpan path, ref FuseFileInfo fi)
{
handlesManager.CloseHandle(fi.fh);
@@ -487,7 +524,6 @@ public override unsafe int StatFS(ReadOnlySpan path, ref statvfs statfs)
return 0;
}
- [MethodImpl(MethodImplOptions.Synchronized)]
public override int Truncate(ReadOnlySpan path, ulong length, FuseFileInfoRef fiRef)
{
if (FuseOptions!.IsReadOnly)
@@ -504,13 +540,14 @@ public override int Truncate(ReadOnlySpan path, ulong length, FuseFileInfo
return -ENOENT;
FuseFileHandle? handle;
+ var temporaryHandleId = FileSystem.Constants.INVALID_HANDLE;
if (fiRef.IsNull)
{
- var newHandle = handlesManager.OpenFileHandle(ciphertextPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite, FileOptions.None);
- if (newHandle == FileSystem.Constants.INVALID_HANDLE)
+ temporaryHandleId = handlesManager.OpenFileHandle(ciphertextPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite, FileOptions.None);
+ if (temporaryHandleId == FileSystem.Constants.INVALID_HANDLE)
return -EIO;
- handle = handlesManager.GetHandle(newHandle)!;
+ handle = handlesManager.GetHandle(temporaryHandleId)!;
}
else
{
@@ -519,11 +556,23 @@ public override int Truncate(ReadOnlySpan path, ulong length, FuseFileInfo
return -EBADF;
}
- var position = handle.Stream.Position;
- handle.Stream.SetLength((long)length);
- handle.Stream.Position = position;
+ try
+ {
+ lock (handle.Stream)
+ {
+ var position = handle.Stream.Position;
+ handle.Stream.SetLength((long)length);
+ handle.Stream.Position = position;
+ }
- return 0;
+ return 0;
+ }
+ finally
+ {
+ // Close the temporary handle
+ if (temporaryHandleId != FileSystem.Constants.INVALID_HANDLE)
+ handlesManager.CloseHandle(temporaryHandleId);
+ }
}
///
@@ -569,13 +618,14 @@ public override unsafe int UpdateTimestamps(ReadOnlySpan path, ref timespe
return -errno;
var result = futimens(*(int*)fd, times);
- UnsafeNativeApis.CloseDir(fd);
+ CloseDir(fd);
if (result == -1)
return -errno;
return 0;
}
+
if (File.Exists(ciphertextPath))
{
var fd = open(ciphertextPathPtr, O_WRONLY);
@@ -595,21 +645,24 @@ public override unsafe int UpdateTimestamps(ReadOnlySpan path, ref timespe
}
}
- [MethodImpl(MethodImplOptions.Synchronized)]
public override int Write(ReadOnlySpan path, ulong offset, ReadOnlySpan buffer, ref FuseFileInfo fi)
{
var handle = handlesManager.GetHandle(fi.fh);
if (handle is null || !handle.FileAccess.HasFlag(FileAccess.Write))
return -EBADF;
- if (handle.FileMode == FileMode.Append)
- offset = (ulong)handle.Stream.Length;
-
- if ((long)offset + buffer.Length > handle.Stream.Length)
- handle.Stream.SetLength((long)offset + buffer.Length);
+ // Lock on the handle's stream. The kernel can issue concurrent operations
+ // on the same handle, and setting the position and writing must be atomic
+ lock (handle.Stream)
+ {
+ if (handle.FileMode == FileMode.Append)
+ offset = (ulong)handle.Stream.Length;
- handle.Stream.Position = (long)offset;
- handle.Stream.Write(buffer);
+ // No SetLength needed here as the plaintext stream extends itself and
+ // fills any gap with zeros when writing past the end of the file
+ handle.Stream.Position = (long)offset;
+ handle.Stream.Write(buffer);
+ }
return buffer.Length;
}
From acbae4b481f962dae9e52067c3549bf8d43df1da Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 06:10:04 +0200
Subject: [PATCH 24/55] Cleaned up namespaces
---
src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs
index 9866dd57d..81169e708 100644
--- a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs
+++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs
@@ -1,10 +1,10 @@
-using Microsoft.Extensions.Logging;
-using NWebDav.Server.Dispatching;
-using SecureFolderFS.Core.WebDav.Helpers;
-using System;
+using System;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NWebDav.Server.Dispatching;
+using SecureFolderFS.Core.WebDav.Helpers;
namespace SecureFolderFS.Core.WebDav
{
From c0d3819f789d1d7901f93a7cdefb20a0f7090625 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 13:04:29 +0200
Subject: [PATCH 25/55] Fixed ChannelledStream buffer marshalling
---
.../Android/Streams/ChannelledStream.cs | 21 +++++++++++++++----
1 file changed, 17 insertions(+), 4 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs
index bc5a32813..6a8c6728a 100644
--- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs
+++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs
@@ -49,11 +49,19 @@ public override int Read(byte[] buffer, int offset, int count)
if (offset < 0 || count < 0 || offset + count > buffer.Length)
throw new ArgumentOutOfRangeException();
- var byteBuffer = ByteBuffer.Wrap(buffer, offset, count);
+ // Do NOT use ByteBuffer.Wrap(buffer) here. Managed arrays are marshaled to Java
+ // by copy, and the copy-back into the managed array happens when the invoked
+ // method (Wrap) returns, even before the channel has read anything. The channel would
+ // fill the retained Java-side copy while the managed buffer stays untouched.
+ using var byteBuffer = ByteBuffer.Allocate(count);
var read = _inputChannel.Read(byteBuffer);
- if (read < 0)
+ if (read <= 0)
return 0;
+ // Copy the data back into the managed buffer
+ byteBuffer.Flip();
+ byteBuffer.Get(buffer, offset, read);
+
return read;
}
@@ -66,8 +74,13 @@ public override void Write(byte[] buffer, int offset, int count)
if (offset < 0 || count < 0 || offset + count > buffer.Length)
throw new ArgumentOutOfRangeException();
- var byteBuffer = ByteBuffer.Wrap(buffer, offset, count);
- _outputChannel!.Write(byteBuffer);
+ // Wrap is safe for writing - the managed data is copied into the Java-side
+ // array when Wrap is invoked, which is all the write direction needs
+ using var byteBuffer = ByteBuffer.Wrap(buffer, offset, count);
+
+ // A single write is not guaranteed to consume the whole buffer
+ while (byteBuffer.HasRemaining)
+ _outputChannel!.Write(byteBuffer);
}
///
From bac649be913c94e39b23f0d4f6215790d0557a33 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 14:48:53 +0200
Subject: [PATCH 26/55] Improved iOS thumbnail handling
---
.../StreamedResourceLoaderDelegate.cs | 78 +++++++++++
.../ServiceImplementation/IOSMediaService.cs | 126 +++++++++---------
2 files changed, 144 insertions(+), 60 deletions(-)
create mode 100644 src/Platforms/SecureFolderFS.Maui/Platforms/iOS/AppModels/StreamedResourceLoaderDelegate.cs
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/AppModels/StreamedResourceLoaderDelegate.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/AppModels/StreamedResourceLoaderDelegate.cs
new file mode 100644
index 000000000..8ce4dbfb6
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/AppModels/StreamedResourceLoaderDelegate.cs
@@ -0,0 +1,78 @@
+using AVFoundation;
+using Foundation;
+
+namespace SecureFolderFS.Maui.Platforms.iOS.AppModels
+{
+ ///
+ /// Serves AVFoundation resource loading requests directly from a seekable .
+ ///
+ ///
+ /// This allows AVFoundation to read media with full random access without materializing
+ /// the (decrypted) content in a temporary file. Byte ranges are essential - many videos
+ /// keep their metadata ('moov' atom) at the end of the file.
+ ///
+ internal sealed class StreamedResourceLoaderDelegate : AVAssetResourceLoaderDelegate
+ {
+ // Serve at most this many bytes per request
+ private const int MAX_CHUNK_LENGTH = 1024 * 1024;
+
+ private readonly Stream _sourceStream;
+ private readonly string _contentTypeUti;
+ private readonly object _streamLock = new();
+
+ public StreamedResourceLoaderDelegate(Stream sourceStream, string contentTypeUti)
+ {
+ _sourceStream = sourceStream;
+ _contentTypeUti = contentTypeUti;
+ }
+
+ ///
+ public override bool ShouldWaitForLoadingOfRequestedResource(AVAssetResourceLoader resourceLoader, AVAssetResourceLoadingRequest loadingRequest)
+ {
+ try
+ {
+ var cir = loadingRequest.ContentInformationRequest;
+ if (cir is not null)
+ {
+ cir.ContentType = _contentTypeUti;
+ cir.ByteRangeAccessSupported = true;
+ lock (_streamLock)
+ cir.ContentLength = _sourceStream.Length;
+ }
+
+ if (loadingRequest.DataRequest is { } dataRequest)
+ {
+ // CurrentOffset tracks the progress of partially-served requests
+ var offset = dataRequest.CurrentOffset != 0L ? dataRequest.CurrentOffset : dataRequest.RequestedOffset;
+ var length = (int)Math.Min(dataRequest.RequestedLength, MAX_CHUNK_LENGTH);
+
+ if (length > 0)
+ {
+ int read;
+ var buffer = new byte[length];
+ lock (_streamLock)
+ {
+ _sourceStream.Position = offset;
+ read = _sourceStream.ReadAtLeast(buffer, length, throwOnEndOfStream: false);
+ }
+
+ if (read > 0)
+ {
+ using var data = NSData.FromArray(read == buffer.Length ? buffer : buffer[..read]);
+ dataRequest.Respond(data);
+ }
+ }
+ }
+
+ loadingRequest.FinishLoading();
+ return true;
+ }
+ catch (Exception)
+ {
+ // The source stream may already be closed when late requests arrive
+ loadingRequest.FinishLoadingWithError(new NSError((NSString)$"{nameof(SecureFolderFS)}.ResourceLoader", -1));
+ return true;
+ }
+ }
+ }
+}
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs
index 0a1d7f843..9016dc693 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs
@@ -1,10 +1,12 @@
using AVFoundation;
+using CoreFoundation;
using CoreGraphics;
using CoreMedia;
using Foundation;
using ImageIO;
using OwlCore.Storage;
using SecureFolderFS.Maui.AppModels;
+using SecureFolderFS.Maui.Platforms.iOS.AppModels;
using SecureFolderFS.Maui.ServiceImplementation;
using SecureFolderFS.Sdk.Services;
using SecureFolderFS.Shared.ComponentModel;
@@ -36,10 +38,12 @@ public override async Task GenerateThumbnailAsync(IFile file, Type
case TypeHint.Media:
{
+ // Capture at one second rather than the very first frame, which is often
+ // black (fade-ins). The generator clamps the position for shorter videos
await using var stream = await file.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var extension = Path.GetExtension(file.Name);
- return await GenerateVideoThumbnailAsync(stream, extension, TimeSpan.FromSeconds(0)).ConfigureAwait(false);
+ return await GenerateVideoThumbnailAsync(stream, extension, TimeSpan.FromSeconds(1)).ConfigureAwait(false);
}
default: throw new InvalidOperationException("The provided file type is invalid.");
@@ -69,75 +73,77 @@ private static async Task GenerateImageThumbnailAsync(Stream strea
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((int)jpegData.Length);
- await jpegData.AsStream().CopyToAsync(memoryStream).ConfigureAwait(false);
- memoryStream.Position = 0L;
+ using var encodedData = EncodeImage(image, cgImage.AlphaInfo);
+ if (encodedData is null)
+ throw new FormatException("Failed to encode the thumbnail.");
- return new ImageStreamSource(new NonDisposableStream(memoryStream));
+ return await ToImageStreamAsync(encodedData).ConfigureAwait(false);
}
private static async Task GenerateVideoThumbnailAsync(Stream stream, string extension, TimeSpan captureTime)
{
- var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}{extension}");
- try
+ // Resolve the UTI of the container format for the resource loader
+ var normalizedExtension = extension.TrimStart('.').ToLowerInvariant();
+ var contentType = normalizedExtension switch
{
- // Only read up to a limited prefix of the file. AVFoundation can typically
- // decode the first keyframe from the first few MBs of a well-formed MP4,
- // since moov/stbl metadata is usually at the front.
- const int maxPrefixBytes = 4 * 1024 * 1024; // 4 MB
- await using (var fileStream = File.OpenWrite(tempPath))
- {
- var buffer = new byte[81920];
- int totalRead = 0, read;
- while (totalRead < maxPrefixBytes &&
- (read = await stream
- .ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, maxPrefixBytes - totalRead)))
- .ConfigureAwait(false)) > 0)
- {
- await fileStream.WriteAsync(buffer.AsMemory(0, read)).ConfigureAwait(false);
- totalRead += read;
- }
- }
+ "mp4" or "m4v" => "public.mpeg-4",
+ "mov" or "qt" => "com.apple.quicktime-movie",
+ "mpg" or "mpeg" => "public.mpeg",
+ "avi" => "public.avi",
+ "3gp" => "public.3gpp",
+ _ => "public.movie"
+ };
- var asset = AVAsset.FromUrl(NSUrl.FromFilename(tempPath));
- var generator = new AVAssetImageGenerator(asset)
- {
- AppliesPreferredTrackTransform = true,
- MaximumSize = new CGSize(320, 240)
- };
+ // A custom URL scheme forces AVFoundation to consult the resource loader delegate,
+ // which serves byte ranges directly from the (decrypted) stream. This avoids writing
+ // plaintext to a temporary file and supports videos with metadata at the end of the file
+ var url = NSUrl.FromString($"securefolderfs-stream://thumbnail/video.{normalizedExtension}");
+ if (url is null)
+ throw new FormatException("Failed to create the resource URL.");
- var actualTime = new CMTime((long)captureTime.TotalSeconds, 1);
- var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var times = new[] { NSValue.FromCMTime(actualTime) };
+ using var loaderQueue = new DispatchQueue($"{nameof(SecureFolderFS)}.{nameof(Maui)}.ResourceLoader");
+ using var loaderDelegate = new StreamedResourceLoaderDelegate(stream, contentType);
+ using var asset = AVUrlAsset.Create(url);
+ asset.ResourceLoader.SetDelegate(loaderDelegate, loaderQueue);
- 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)
- throw new FormatException("Failed to convert image to JPEG.");
-
- var memoryStream = new MemoryStream();
- await jpegData.AsStream().CopyToAsync(memoryStream).ConfigureAwait(false);
- memoryStream.Position = 0L;
-
- return new ImageStreamSource(new NonDisposableStream(memoryStream));
- }
- finally
+ using var generator = new AVAssetImageGenerator(asset);
+ generator.AppliesPreferredTrackTransform = true;
+ generator.MaximumSize = new CGSize(320, 320);
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var times = new[] { NSValue.FromCMTime(CMTime.FromSeconds(captureTime.TotalSeconds, 600)) };
+
+ generator.GenerateCGImagesAsynchronously(times, (_, image, _, _, error) =>
{
- File.Delete(tempPath);
- }
+ if (error is not 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 / 100f);
+ if (jpegData is null)
+ throw new FormatException("Failed to convert image to JPEG.");
+
+ return await ToImageStreamAsync(jpegData).ConfigureAwait(false);
+ }
+
+ private static NSData? EncodeImage(UIImage image, CGImageAlphaInfo alphaInfo)
+ {
+ // Use PNG for images with transparency. AsJPEG() expects the compression quality in the 0..1f range
+ var hasAlpha = alphaInfo is not (CGImageAlphaInfo.None or CGImageAlphaInfo.NoneSkipFirst or CGImageAlphaInfo.NoneSkipLast);
+ return hasAlpha ? image.AsPNG() : image.AsJPEG(Constants.Browser.IMAGE_THUMBNAIL_QUALITY / 100f);
+ }
+
+ private static async Task ToImageStreamAsync(NSData imageData)
+ {
+ var memoryStream = new MemoryStream((int)imageData.Length);
+ await imageData.AsStream().CopyToAsync(memoryStream).ConfigureAwait(false);
+ memoryStream.Position = 0L;
+
+ return new ImageStreamSource(new NonDisposableStream(memoryStream));
}
}
}
From 9600573ae6d99ff97f6573ef9f8699d88cb1bfde Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 14:59:36 +0200
Subject: [PATCH 27/55] Fixed overwrite contract in IOSFolder and use
FileShare.Read in IOSFile
---
.../Platforms/iOS/Storage/IOSFile.cs | 5 +-
.../Platforms/iOS/Storage/IOSFolder.cs | 89 +++++++++++++------
.../Platforms/iOS/Storage/IOSStorable.cs | 21 ++---
3 files changed, 75 insertions(+), 40 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs
index 14514e1c5..e90c5f7e7 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs
@@ -27,10 +27,11 @@ public IOSFile(NSUrl url, IOSFolder? parent = null, NSUrl? permissionRoot = null
///
public Task OpenStreamAsync(FileAccess accessMode, CancellationToken cancellationToken = default)
{
+ // Allow concurrent readers by default (FileShare.Read)
var iosStream = accessMode switch
{
- FileAccess.ReadWrite or FileAccess.Write => new IOSSecurityScopedStream(Inner, permissionRoot, FileAccess.ReadWrite),
- _ => new IOSSecurityScopedStream(Inner, permissionRoot, FileAccess.Read)
+ FileAccess.ReadWrite or FileAccess.Write => new IOSSecurityScopedStream(Inner, permissionRoot, FileAccess.ReadWrite, FileShare.Read),
+ _ => new IOSSecurityScopedStream(Inner, permissionRoot, FileAccess.Read, FileShare.Read)
};
return Task.FromResult(iosStream);
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs
index dfc5069ec..3fe898c6e 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs
@@ -31,13 +31,13 @@ public IOSFolder(NSUrl url, IOSFolder? parent = null, NSUrl? permissionRoot = nu
///
public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
- if (!permissionRoot.StartAccessingSecurityScopedResource())
- throw new UnauthorizedAccessException("Could not get iOS items.");
-
+ var accessStarted = permissionRoot.StartAccessingSecurityScopedResource();
var coordinator = new NSFileCoordinator();
try
{
- var tcs = new TaskCompletionSource>();
+ var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var cancellationRegistration = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken));
+
coordinator.CoordinateRead(Inner,
NSFileCoordinatorReadingOptions.WithoutChanges,
out var error,
@@ -58,20 +58,31 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type =
var items = await tcs.Task;
foreach (var item in items)
- yield return item;
+ {
+ // Honor the requested item type
+ var matchesType = type switch
+ {
+ StorableType.File => item is IFile,
+ StorableType.Folder => item is IFolder,
+ _ => true
+ };
+
+ if (matchesType)
+ yield return item;
+ }
}
finally
{
coordinator.Dispose();
- permissionRoot.StopAccessingSecurityScopedResource();
+ if (accessStarted)
+ permissionRoot.StopAccessingSecurityScopedResource();
}
}
///
public async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken = default)
{
- if (!permissionRoot.StartAccessingSecurityScopedResource())
- throw new UnauthorizedAccessException("Could not get iOS item.");
+ var accessStarted = permissionRoot.StartAccessingSecurityScopedResource();
try
{
var isDirectory = false;
@@ -84,7 +95,9 @@ public async Task GetFirstByNameAsync(string name, CancellationT
}
finally
{
- permissionRoot.StopAccessingSecurityScopedResource();
+ if (accessStarted)
+ permissionRoot.StopAccessingSecurityScopedResource();
+
await Task.CompletedTask;
}
}
@@ -101,8 +114,7 @@ public async Task DeleteAsync(IStorableChild item, CancellationToken cancellatio
if (item is not IWrapper iosWrapper)
throw new ArgumentException("Storable item must wrap an NSUrl.", nameof(item));
- if (!permissionRoot.StartAccessingSecurityScopedResource())
- throw new UnauthorizedAccessException("Could not delete iOS item.");
+ var accessStarted = permissionRoot.StartAccessingSecurityScopedResource();
try
{
if (!NSFileManager.DefaultManager.Remove(iosWrapper.Inner, out var error))
@@ -110,7 +122,9 @@ public async Task DeleteAsync(IStorableChild item, CancellationToken cancellatio
}
finally
{
- permissionRoot.StopAccessingSecurityScopedResource();
+ if (accessStarted)
+ permissionRoot.StopAccessingSecurityScopedResource();
+
await Task.CompletedTask;
}
}
@@ -118,13 +132,22 @@ public async Task DeleteAsync(IStorableChild item, CancellationToken cancellatio
///
public async Task CreateFolderAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default)
{
- if (!permissionRoot.StartAccessingSecurityScopedResource())
- throw new UnauthorizedAccessException("Could not create iOS folder.");
+ var accessStarted = permissionRoot.StartAccessingSecurityScopedResource();
try
{
var path = Path.Combine(Id, name);
NSFileAttributes? attributes = null;
+ // Return the existing folder. "False" overwrite contract is get-or-create
+ var isDirectory = false;
+ if (NSFileManager.DefaultManager.FileExists(path, ref isDirectory))
+ {
+ if (!isDirectory)
+ throw new IOException($"A file with the name '{name}' already exists.");
+
+ return new IOSFolder(new NSUrl(path, true), this, permissionRoot);
+ }
+
if (NSFileManager.DefaultManager.CreateDirectory(path, false, attributes, out var error))
return new IOSFolder(new NSUrl(path, true), this, permissionRoot);
@@ -135,7 +158,9 @@ public async Task CreateFolderAsync(string name, bool overwrite =
}
finally
{
- permissionRoot.StopAccessingSecurityScopedResource();
+ if (accessStarted)
+ permissionRoot.StopAccessingSecurityScopedResource();
+
await Task.CompletedTask;
}
}
@@ -143,14 +168,17 @@ public async Task CreateFolderAsync(string name, bool overwrite =
///
public async Task CreateFileAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default)
{
- if (!permissionRoot.StartAccessingSecurityScopedResource())
- throw new UnauthorizedAccessException("Could not create iOS file.");
+ var accessStarted = permissionRoot.StartAccessingSecurityScopedResource();
try
{
-
var path = Path.Combine(Id, name);
NSFileAttributes? attributes = null;
+ // NSFileManager.CreateFile unconditionally replaces existing content, but the
+ // contract for overwrite=false requires returning the existing file untouched
+ if (!overwrite && NSFileManager.DefaultManager.FileExists(path))
+ return new IOSFile(new NSUrl(path, false), this, permissionRoot);
+
if (NSFileManager.DefaultManager.CreateFile(path, new NSData(), attributes))
return new IOSFile(new NSUrl(path, false), this, permissionRoot);
@@ -158,7 +186,9 @@ public async Task CreateFileAsync(string name, bool overwrite = fals
}
finally
{
- permissionRoot.StopAccessingSecurityScopedResource();
+ if (accessStarted)
+ permissionRoot.StopAccessingSecurityScopedResource();
+
await Task.CompletedTask;
}
}
@@ -177,8 +207,7 @@ public async Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite
if (fileToCopy is not IOSFile iosFile)
return await fallback(this, fileToCopy, overwrite, newName, cancellationToken);
- if (!permissionRoot.StartAccessingSecurityScopedResource())
- throw new UnauthorizedAccessException("Could not copy iOS file.");
+ var accessStarted = permissionRoot.StartAccessingSecurityScopedResource();
try
{
var newPath = Path.Combine(Id, newName);
@@ -213,7 +242,9 @@ public async Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite
}
finally
{
- permissionRoot.StopAccessingSecurityScopedResource();
+ if (accessStarted)
+ permissionRoot.StopAccessingSecurityScopedResource();
+
await Task.CompletedTask;
}
}
@@ -233,8 +264,7 @@ public async Task MoveFromAsync(IChildFile fileToMove, IModifiableFo
if (fileToMove is not IOSFile iosFile)
return await fallback(this, fileToMove, source, overwrite, newName, cancellationToken);
- if (!permissionRoot.StartAccessingSecurityScopedResource())
- throw new UnauthorizedAccessException("Could not move iOS file.");
+ var accessStarted = permissionRoot.StartAccessingSecurityScopedResource();
try
{
var newPath = Path.Combine(Id, newName);
@@ -269,7 +299,9 @@ public async Task MoveFromAsync(IChildFile fileToMove, IModifiableFo
}
finally
{
- permissionRoot.StopAccessingSecurityScopedResource();
+ if (accessStarted)
+ permissionRoot.StopAccessingSecurityScopedResource();
+
await Task.CompletedTask;
}
}
@@ -277,8 +309,7 @@ public async Task MoveFromAsync(IChildFile fileToMove, IModifiableFo
///
public async Task RenameAsync(IStorableChild storable, string newName, CancellationToken cancellationToken = default)
{
- if (!permissionRoot.StartAccessingSecurityScopedResource())
- throw new UnauthorizedAccessException("Could not rename iOS item.");
+ var accessStarted = permissionRoot.StartAccessingSecurityScopedResource();
try
{
if (storable is not IWrapper iosWrapper)
@@ -300,7 +331,9 @@ public async Task RenameAsync(IStorableChild storable, string ne
}
finally
{
- permissionRoot.StopAccessingSecurityScopedResource();
+ if (accessStarted)
+ permissionRoot.StopAccessingSecurityScopedResource();
+
await Task.CompletedTask;
}
}
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSStorable.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSStorable.cs
index a9b6bf1ce..f81229b62 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSStorable.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSStorable.cs
@@ -2,7 +2,6 @@
using OwlCore.Storage;
using SecureFolderFS.Shared.ComponentModel;
using SecureFolderFS.Storage;
-using UIKit;
using Constants = SecureFolderFS.UI.Constants;
namespace SecureFolderFS.Maui.Platforms.iOS.Storage
@@ -47,21 +46,24 @@ protected IOSStorable(NSUrl url, IOSFolder? parent = null, NSUrl? permissionRoot
///
public virtual async Task AddBookmarkAsync(CancellationToken cancellationToken = default)
{
+ // StartAccessingSecurityScopedResource returns false for URLs that are not
+ // security-scoped (e.g., items in the app sandbox) - proceed in that case,
+ // and only balance with a Stop call when access was actually started
+ var accessStarted = permissionRoot.StartAccessingSecurityScopedResource();
try
{
- if (!permissionRoot.StartAccessingSecurityScopedResource())
- return;
-
var nsDataBookmark = Inner.CreateBookmarkData(NSUrlBookmarkCreationOptions.SuitableForBookmarkFile, Array.Empty(), null, out var error);
if (error is not null)
- return;
+ throw new NSErrorException(error);
var nsEncodedBookmark = nsDataBookmark.GetBase64EncodedString(NSDataBase64EncodingOptions.None);
BookmarkId = $"{Constants.STORABLE_BOOKMARK_RID}{nsEncodedBookmark}";
}
finally
{
- permissionRoot.StopAccessingSecurityScopedResource();
+ if (accessStarted)
+ permissionRoot.StopAccessingSecurityScopedResource();
+
await Task.CompletedTask;
}
}
@@ -75,10 +77,9 @@ public virtual Task RemoveBookmarkAsync(CancellationToken cancellationToken = de
protected static void GetImmediateProperties(NSUrl url, out string? id, out string? name)
{
- using var document = new UIDocument(url);
- id = document.FileUrl?.Path ?? url.FilePathUrl?.Path;
- name = !string.IsNullOrEmpty(document.LocalizedName)
- ? document.LocalizedName : (Path.GetFileName(id) ?? url.FilePathUrl?.LastPathComponent);
+ // Resolve the path directly from the URL
+ id = url.FilePathUrl?.Path ?? url.Path;
+ name = Path.GetFileName(id?.TrimEnd('/')) ?? url.LastPathComponent;
}
protected static IStorableChild NewStorage(NSUrl url, IOSFolder? parent = null, NSUrl? permissionRoot = null)
From 9c96062eab495c707d86afb8731bff2d8aa01915 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:08:21 +0200
Subject: [PATCH 28/55] Use ReadExactlyAsync in ThumbnailCacheModel
---
.../AppModels/ThumbnailCacheModel.cs | 25 +++++++++++++------
1 file changed, 18 insertions(+), 7 deletions(-)
diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs
index 223c39560..823049b80 100644
--- a/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs
@@ -80,10 +80,10 @@ public async Task CacheThumbnailAsync(string cacheKey, IImageStream thumbnailStr
var data = new byte[thumbnailStream.Inner.Length];
var savedPosition = thumbnailStream.Inner.Position;
thumbnailStream.Inner.Position = 0L;
- var read = await thumbnailStream.Inner.ReadAsync(data, cancellationToken);
+
+ // A single call to ReadAsync is not guaranteed to fill the buffer
+ await thumbnailStream.Inner.ReadExactlyAsync(data, cancellationToken);
thumbnailStream.Inner.Position = savedPosition;
- if (read != data.Length)
- return;
await _database.SetValueAsync(cacheKey, data, cancellationToken);
}
@@ -113,8 +113,19 @@ public Task ClearCacheAsync(CancellationToken cancellationToken = default)
/// A unique cache key string.
public static async Task GetCacheKeyAsync(IFile file, CancellationToken cancellationToken)
{
- var pathHash = GetPathHash(file.Id);
var dateModified = await file.GetDateModifiedAsync(cancellationToken);
+ return GetCacheKey(file.Id, dateModified);
+ }
+
+ ///
+ /// Generates a cache key from an already-known file ID and modification date.
+ ///
+ /// The unique ID of the file.
+ /// The file's last modification date, if known.
+ /// A unique cache key string.
+ public static string GetCacheKey(string id, DateTime? dateModified)
+ {
+ var pathHash = GetPathHash(id);
if (dateModified is null)
return pathHash;
@@ -126,11 +137,11 @@ public static async Task GetCacheKeyAsync(IFile file, CancellationToken
///
/// Generates a hash of the file path using SHA256.
///
- /// The file path to hash.
+ /// The file path to hash.
/// A hexadecimal hash string.
- private static string GetPathHash(string filePath)
+ private static string GetPathHash(string id)
{
- var bytes = Encoding.UTF8.GetBytes(filePath);
+ var bytes = Encoding.UTF8.GetBytes(id);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
From c56419c7b59c9ef81b9e6e4922ce1a422eac3378 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:14:38 +0200
Subject: [PATCH 29/55] Preemptively dispose security scope in
IOSSecurityScopedStream on failure
---
.../iOS/Storage/IOSSecurityScopedStream.cs | 34 ++++++++++++++-----
1 file changed, 25 insertions(+), 9 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSSecurityScopedStream.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSSecurityScopedStream.cs
index 51932c186..1e648a9dc 100644
--- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSSecurityScopedStream.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSSecurityScopedStream.cs
@@ -12,8 +12,8 @@ The above copyright notice and this permission notice shall be included in all c
*/
+using System.Runtime.CompilerServices;
using Foundation;
-using UIKit;
namespace SecureFolderFS.Maui.Platforms.iOS.Storage
{
@@ -22,17 +22,31 @@ internal sealed class IOSSecurityScopedStream : Stream
{
private readonly NSUrl _url;
private readonly FileStream _stream;
- private readonly UIDocument _document;
+ private readonly bool _accessStarted;
private readonly NSUrl _securityScopedAncestorUrl;
- internal IOSSecurityScopedStream(NSUrl url, NSUrl securityScopedAncestorUrl, FileAccess access, FileShare share = FileShare.None)
+ internal IOSSecurityScopedStream(NSUrl url, NSUrl securityScopedAncestorUrl, FileAccess access, FileShare share)
{
- _document = new UIDocument(url);
- var path = _document.FileUrl.Path!;
_url = url;
_securityScopedAncestorUrl = securityScopedAncestorUrl;
- _securityScopedAncestorUrl.StartAccessingSecurityScopedResource();
- _stream = File.Open(path, FileMode.Open, access, share);
+
+ // Track whether access was actually granted. Security scope access is reference
+ // counted per URL, and an unbalanced Stop could revoke access held elsewhere
+ _accessStarted = _securityScopedAncestorUrl.StartAccessingSecurityScopedResource();
+ try
+ {
+ var path = url.FilePathUrl?.Path ?? url.Path!;
+ _stream = File.Open(path, FileMode.Open, access, share);
+ }
+ catch (Exception)
+ {
+ // Stop the security scope when the stream could not be opened,
+ // as Dispose will never run for a failed constructor
+ if (_accessStarted)
+ _securityScopedAncestorUrl.StopAccessingSecurityScopedResource();
+
+ throw;
+ }
}
///
@@ -75,15 +89,17 @@ public override void Write(byte[] buffer, int offset, int count) =>
_stream.Write(buffer, offset, count);
///
+ [MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
protected override void Dispose(bool disposing)
{
+ _ = _url; // _url is kept intentionally
base.Dispose(disposing);
if (disposing)
{
_stream.Dispose();
- _document.Dispose();
- _securityScopedAncestorUrl.StopAccessingSecurityScopedResource();
+ if (_accessStarted)
+ _securityScopedAncestorUrl.StopAccessingSecurityScopedResource();
}
}
}
From efe7fbd7b9cd4f47ff65abb0af7623084f77cf38 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:40:47 +0200
Subject: [PATCH 30/55] Fixed thumbnail cache race condition
---
.../Browser/BrowserControl.Rendering.xaml.cs | 6 +++---
.../Controls/Storage/Browser/FileViewModel.cs | 11 +++++++----
2 files changed, 10 insertions(+), 7 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 50f7cb27e..43cf5900d 100644
--- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs
@@ -143,10 +143,10 @@ private void ItemContainer_Loaded(object? sender, EventArgs e)
private void ItemContainer_BindingContextChanged(object? sender, EventArgs e)
{
-#if IOS
- // Also handle BindingContextChanged for virtualized/recycled items on iOS
+ // Recycled containers do not re-fire Loaded (RecyclerView on Android, UICollectionView
+ // on iOS), so items scrolled into view in reused cells would never initialize.
+ // Duplicate calls for freshly loaded containers are deduplicated by the item's InitAsync
TryEnqueueItem(sender);
-#endif
}
private void ItemsCollectionView_Loaded(object? sender, EventArgs e)
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 4f0282e0f..35adda17f 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs
@@ -81,9 +81,9 @@ private async Task PerformFileLoadAsync(CancellationToken cancellationToken)
if (!SettingsService.UserSettings.AreThumbnailsEnabled || !CanLoadThumbnail())
return;
- // Try to get from the cache first
+ // Try to get from the cache first and reuse the already-fetched modification date instead of querying it again
Thumbnail?.Dispose();
- var cacheKey = await ThumbnailCacheModel.GetCacheKeyAsync(File, cancellationToken);
+ var cacheKey = ThumbnailCacheModel.GetCacheKey(File.Id, LastModified);
var cachedStream = await BrowserViewModel.ThumbnailCache.TryGetCachedThumbnailAsync(cacheKey, cancellationToken);
if (cachedStream is not null)
{
@@ -97,9 +97,12 @@ private async Task PerformFileLoadAsync(CancellationToken cancellationToken)
if (generatedThumbnail is null)
return;
- // Show and cache the generated thumbnail
+ // Cache the thumbnail BEFORE handing the stream to the UI (use await) because both the cache copy
+ // and the image decoder reposition the same stream, so they must not overlap
+ await BrowserViewModel.ThumbnailCache.CacheThumbnailAsync(cacheKey, generatedThumbnail, cancellationToken);
+
+ // Show the generated thumbnail
Thumbnail = generatedThumbnail;
- _ = BrowserViewModel.ThumbnailCache.CacheThumbnailAsync(cacheKey, generatedThumbnail, cancellationToken);
}
///
From 05d76c942099763f178840d11add7d154571c3a8 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:53:28 +0200
Subject: [PATCH 31/55] Clear completed tcs in TransferViewModel
---
.../Controls/Transfer/TransferViewModel.cs | 25 +++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs
index daeffdd17..8f693ed07 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs
@@ -11,6 +11,7 @@
using SecureFolderFS.Sdk.Extensions;
using SecureFolderFS.Sdk.ViewModels.Views.Vault;
using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Shared.Extensions;
using SecureFolderFS.Storage.Pickers;
namespace SecureFolderFS.Sdk.ViewModels.Controls.Transfer
@@ -19,6 +20,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Transfer
public sealed partial class TransferViewModel : ObservableObject, IViewable, IProgress, IFolderPicker
{
private readonly BrowserViewModel _browserViewModel;
+ private readonly SynchronizationContext? _synchronizationContext;
private TaskCompletionSource? _tcs;
private CancellationTokenSource? _cts;
@@ -32,10 +34,29 @@ public sealed partial class TransferViewModel : ObservableObject, IViewable, IPr
public TransferViewModel(BrowserViewModel browserViewModel)
{
_browserViewModel = browserViewModel;
+ _synchronizationContext = SynchronizationContext.Current;
}
///
public void Report(TotalProgress value)
+ {
+ // Progress is reported from worker threads, but Title is UI-bound.
+ // Marshal the update back to the context this view model was created on
+ if (_synchronizationContext != SynchronizationContext.Current)
+ {
+ _synchronizationContext.PostOrExecute(static state =>
+ {
+ var (self, progress) = ((TransferViewModel, TotalProgress))state!;
+ self.ReportCore(progress);
+ }, (this, value));
+
+ return;
+ }
+
+ ReportCore(value);
+ }
+
+ private void ReportCore(TotalProgress value)
{
if (value.Achieved >= value.Total && value.Total > 0)
{
@@ -94,6 +115,10 @@ public CancellationTokenSource GetCancellation(CancellationToken? linkToken = nu
IsPickingFolder = false;
if (_tcs?.TrySetCanceled(CancellationToken.None) ?? false)
await this.HideAsync();
+
+ // Clear the completed source, otherwise a later Cancel would be routed
+ // to the finished pick instead of cancelling the running transfer
+ _tcs = null;
}
}
From 2c067aa52da70b0eb40fa5e4e2957a687e406cce Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 21:45:45 +0200
Subject: [PATCH 32/55] Fixed thumbnail and carousel loading
---
src/Platforms/Directory.Packages.props | 2 +-
.../AppModels/ImageStreamSource.cs | 12 ++++++++++
.../BaseMauiMediaService.cs | 22 +++++++++++++++++--
.../UserControls/Common/PanPinchContainer.cs | 2 +-
.../ValueConverters/ImageToSourceConverter.cs | 15 +++++++++++++
.../Modals/Vault/FilePreviewModalPage.xaml | 5 +++++
.../Modals/Vault/FilePreviewModalPage.xaml.cs | 9 +++++---
.../Strings/en-US/Resources.resx | 3 +++
8 files changed, 63 insertions(+), 7 deletions(-)
diff --git a/src/Platforms/Directory.Packages.props b/src/Platforms/Directory.Packages.props
index dba561f37..f8fb1cb59 100644
--- a/src/Platforms/Directory.Packages.props
+++ b/src/Platforms/Directory.Packages.props
@@ -11,7 +11,7 @@
-
+
diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs
index 62c313e43..92bfdf69b 100644
--- a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs
+++ b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs
@@ -28,6 +28,18 @@ public ImageStreamSource(Stream inner)
};
}
+ public ImageStreamSource(byte[] imageData)
+ {
+ // Serve a fresh stream per request - the platform image loader disposes the
+ // stream after decoding and may request it again (recycled views, re-layouts).
+ // A single shared stream would be dead by the time a second request arrives
+ Inner = new MemoryStream(imageData, writable: false);
+ Source = new StreamImageSource
+ {
+ Stream = _ => Task.FromResult(new MemoryStream(imageData, writable: false))
+ };
+ }
+
///
public void Dispose()
{
diff --git a/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs
index a66ee72e9..b935d6fa8 100644
--- a/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs
+++ b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs
@@ -50,8 +50,26 @@ 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 ImageStreamSource(stream);
+ // Buffer the decrypted image and serve a fresh stream per request. Handing the
+ // live stream to the UI is fragile: the platform image loader disposes the stream
+ // after decoding and may request it again (re-layouts, recycled views), and large
+ // non-buffered streams cannot be rewound by Android's image pipeline
+ await using var stream = await file.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken);
+
+ byte[] imageData;
+ if (stream.CanSeek)
+ {
+ imageData = new byte[stream.Length];
+ await stream.ReadExactlyAsync(imageData, cancellationToken);
+ }
+ else
+ {
+ await using var buffered = new MemoryStream();
+ await stream.CopyToAsync(buffered, cancellationToken);
+ imageData = buffered.ToArray();
+ }
+
+ return new ImageStreamSource(imageData);
}
///
diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Common/PanPinchContainer.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Common/PanPinchContainer.cs
index 6f5b7e30e..cce6f7927 100644
--- a/src/Platforms/SecureFolderFS.Maui/UserControls/Common/PanPinchContainer.cs
+++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Common/PanPinchContainer.cs
@@ -183,7 +183,7 @@ private async void GestureRecognizer_PanUpdated(object? sender, PanUpdatedEventA
else if (e.StatusType == GestureStatus.Canceled)
{
Content.TranslationX = _panX;
- Content.TranslationY = _panX;
+ Content.TranslationY = _panY;
}
}
diff --git a/src/Platforms/SecureFolderFS.Maui/ValueConverters/ImageToSourceConverter.cs b/src/Platforms/SecureFolderFS.Maui/ValueConverters/ImageToSourceConverter.cs
index 11160e958..6ec38961f 100644
--- a/src/Platforms/SecureFolderFS.Maui/ValueConverters/ImageToSourceConverter.cs
+++ b/src/Platforms/SecureFolderFS.Maui/ValueConverters/ImageToSourceConverter.cs
@@ -2,6 +2,7 @@
using System.Globalization;
using SecureFolderFS.Maui.AppModels;
using SecureFolderFS.Maui.Helpers;
+using SecureFolderFS.Shared.Extensions;
using SecureFolderFS.Shared.Models;
using SecureFolderFS.UI.Enums;
@@ -15,6 +16,20 @@ internal sealed class ImageToSourceConverter : IValueConverter
return value switch
{
ImageStreamSource imageStream => imageStream.Source,
+
+ // Cached thumbnails arrive as StreamImageModel. Without this case they
+ // would silently convert to null and never render
+ StreamImageModel streamImage => ImageSource.FromStream(() =>
+ {
+ // Serve a fresh stream per request - the platform image loader disposes
+ // the stream after decoding and may request it again (recycled views).
+ // MemoryStream.ToArray is safe to call even on a disposed instance
+ if (streamImage.Inner is MemoryStream memoryStream)
+ return new MemoryStream(memoryStream.ToArray(), writable: false);
+
+ streamImage.Inner.TrySetPositionOrAdvance(0L);
+ return streamImage.Inner;
+ }),
ImageIcon iconImage => new FontImageSource()
{
Glyph = GetDescription(iconImage.MauiIcon.Icon),
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml
index 104cb129e..e8b097898 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml
@@ -25,10 +25,15 @@
+
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml.cs b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml.cs
index 7fba25df4..151b072d9 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml.cs
@@ -196,14 +196,17 @@ private void MediaPlayerElement_Loaded(object? sender, EventArgs e)
private void GalleryView_Loaded(object? sender, EventArgs e)
{
- if (GalleryView is not null)
+ if (sender is not GalleryView { BindingContext: CarouselPreviewerViewModel carouselViewModel } galleryView)
return;
- if (sender is not GalleryView { BindingContext: CarouselPreviewerViewModel carouselViewModel } galleryView)
+ // Track parent size on every invocation - the one-time setup below may run
+ // before the first layout pass, when the parent is not measured yet
+ galleryView.FitToParent();
+
+ if (GalleryView is not null)
return;
GalleryView = galleryView;
- galleryView.FitToParent();
galleryView.PreviousRequested += Gallery_PreviousRequested;
galleryView.NextRequested += Gallery_NextRequested;
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx
index 3e02b7c7c..daadd0864 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx
@@ -1418,6 +1418,9 @@
Tap to lock all vaults
+
+ Lock all
+
{0:plural:One vault is|{} vaults are|{} vaults are} unlocked
From 413c01d94f2d336a13fcf02dda15d4ed825aed96 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 22:06:39 +0200
Subject: [PATCH 33/55] Improved size calculation when recycling files in
browser control
---
.../Storage/Browser/BrowserItemViewModel.cs | 91 +++++++++++++------
1 file changed, 61 insertions(+), 30 deletions(-)
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs
index d6801df64..c56f3cf77 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs
@@ -117,12 +117,12 @@ protected virtual async Task MoveAsync(CancellationToken cancellationToken)
if (destinationViewModel is null || destinationViewModel.Inner.Id != destination.Id)
return;
- if (items.Any(item => destination.Id.Contains(item.Inner.Id, StringComparison.InvariantCultureIgnoreCase)))
+ if (items.Any(item => IsAncestorOrSelf(destination.Id, item.Inner.Id)))
return;
- // Ensure the destination has content already loaded
+ // Ensure the destination has content already loaded before collision checks
if (destinationViewModel.Items.IsEmpty())
- _ = destinationViewModel.ListContentsAsync(cts.Token);
+ await destinationViewModel.ListContentsAsync(cts.Token);
await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner), async (item, reporter, token) =>
{
@@ -190,12 +190,12 @@ protected virtual async Task CopyAsync(CancellationToken cancellationToken)
if (destinationViewModel is null || destinationViewModel.Inner.Id != destination.Id)
return;
- if (items.Any(item => destination.Id.Contains(item.Inner.Id, StringComparison.InvariantCultureIgnoreCase)))
+ if (items.Any(item => IsAncestorOrSelf(destination.Id, item.Inner.Id)))
return;
- // Ensure the destination has content already loaded
+ // Ensure the destination has content already loaded before collision checks
if (destinationViewModel.Items.IsEmpty())
- _ = destinationViewModel.ListContentsAsync(cts.Token);
+ await destinationViewModel.ListContentsAsync(cts.Token);
await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, reporter, token) =>
{
@@ -205,10 +205,6 @@ await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, r
// Copy
var copiedItem = await modifiableDestination.CreateCopyOfStorableAsync(item, false, availableName, reporter, token);
- // Ensure the destination has content already loaded
- if (destinationViewModel.Items.IsEmpty())
- _ = destinationViewModel.ListContentsAsync(cts.Token);
-
// Add to destination
destinationViewModel.Items.Insert(copiedItem switch
{
@@ -312,23 +308,24 @@ protected virtual async Task DeleteAsync(CancellationToken cancellationToken)
if (recycleBin is null)
return;
- var sizes = new List();
+ // Key the sizes by item ID - the transfer callback receives the storable,
+ // so positional lookups against the view model array would not match
+ var sizes = new Dictionary();
foreach (var item in items)
{
- sizes.Add(item.Inner switch
+ sizes[item.Inner.Id] = item.Inner switch
{
IFile file => await file.GetSizeAsync(cts.Token) ?? 0L,
IFolder folder => await folder.GetSizeAsync(cts.Token) ?? 0L,
_ => 0L
- });
+ };
}
if (BrowserViewModel.Options.IsRecycleBinUnlimited())
{
await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner), async (item, token) =>
{
- var idx = Array.IndexOf(items, item);
- await recyclableFolder.DeleteAsync(item, sizes[idx], false, token);
+ await recyclableFolder.DeleteAsync(item, sizes.GetValueOrDefault(item.Id, 0L), false, token);
var itemToRemove = ParentFolder.Items.FirstOrDefault(x => x.Inner.Id == item.Id);
if (itemToRemove is not null)
@@ -339,7 +336,7 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner)
{
var occupiedSize = await recycleBin.GetSizeAsync(cts.Token);
var availableSize = BrowserViewModel.Options.RecycleBinSize - occupiedSize;
- var permanently = availableSize < sizes.Sum();
+ var permanently = availableSize < sizes.Values.Sum();
if (permanently)
{
@@ -356,16 +353,13 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner)
return;
}
- var idx = 0;
await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner), async (item, token) =>
{
- await recyclableFolder.DeleteAsync(item, sizes[idx], permanently, token);
+ await recyclableFolder.DeleteAsync(item, sizes.GetValueOrDefault(item.Id, 0L), permanently, token);
var itemToRemove = ParentFolder.Items.FirstOrDefault(x => x.Inner.Id == item.Id);
if (itemToRemove is not null)
ParentFolder.Items.RemoveAndGet(itemToRemove)?.Dispose();
-
- idx++;
}, cts.Token);
}
}
@@ -410,7 +404,7 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner)
[RelayCommand]
protected virtual async Task ExportAsync(CancellationToken cancellationToken)
{
- if (ParentFolder?.Folder is not IModifiableFolder parentModifiableFolder)
+ if (ParentFolder is null)
return;
if (BrowserViewModel.TransferViewModel is not { IsProgressing: false } transferViewModel)
@@ -424,19 +418,56 @@ protected virtual async Task ExportAsync(CancellationToken cancellationToken)
if (destination is not IModifiableFolder destinationFolder)
return;
- transferViewModel.TransferType = TransferType.Move;
- using var cts = transferViewModel.GetCancellation(cancellationToken);
- await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, reporter, token) =>
- {
- // Copy and delete
- await destinationFolder.CreateCopyOfStorableAsync(item, false, reporter, token);
- await parentModifiableFolder.DeleteAsync((IStorableChild)item, token);
+ // Sources in read-only vaults cannot be removed - export becomes a copy
+ var removeSource = !BrowserViewModel.Options.IsReadOnly && ParentFolder.Folder is IModifiableFolder;
- ParentFolder.Items.RemoveMatch(x => x.Inner.Id == item.Id)?.Dispose();
- }, cts.Token);
+ try
+ {
+ transferViewModel.TransferType = removeSource ? TransferType.Move : TransferType.Copy;
+ using var cts = transferViewModel.GetCancellation(cancellationToken);
+ await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, reporter, token) =>
+ {
+ // Copy and delete
+ await destinationFolder.CreateCopyOfStorableAsync(item, false, reporter, token);
+ if (removeSource && ParentFolder.Folder is IModifiableFolder parentModifiableFolder)
+ {
+ await parentModifiableFolder.DeleteAsync((IStorableChild)item, token);
+ ParentFolder.Items.RemoveMatch(x => x.Inner.Id == item.Id)?.Dispose();
+ }
+ }, cts.Token);
+ }
+ catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
+ {
+ _ = ex;
+ // TODO: Report error
+ }
+ finally
+ {
+ await transferViewModel.HideAsync();
+ }
}
[RelayCommand]
protected abstract Task OpenAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Determines whether points to itself
+ /// or to a descendant of it, respecting path segment boundaries.
+ ///
+ ///
+ /// A plain substring check would misclassify sibling paths that share a prefix (e.g. '/a/bc' and '/a/b').
+ ///
+ private static bool IsAncestorOrSelf(string destinationId, string itemId)
+ {
+ if (destinationId.Equals(itemId, StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ if (!destinationId.StartsWith(itemId, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ // The prefix must end exactly at a path separator boundary
+ return itemId.EndsWith('/') || itemId.EndsWith('\\')
+ || destinationId[itemId.Length] is '/' or '\\';
+ }
}
}
From 158259f89938f11cee92f2a5e524a6f133137eb7 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 22:10:33 +0200
Subject: [PATCH 34/55] Avoid redundant MIME type lookups in applying adaptive
layout
---
.../Storage/Browser/FolderViewModel.cs | 98 ++++++++++++-------
1 file changed, 64 insertions(+), 34 deletions(-)
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 b87568a33..b673cd320 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs
@@ -22,6 +22,8 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser
[Bindable(true)]
public partial class FolderViewModel : BrowserItemViewModel, IViewDesignation
{
+ private CancellationTokenSource? _listingCts;
+
///
/// Gets the folder associated with this view model.
///
@@ -67,7 +69,6 @@ public virtual void OnAppearing()
///
public virtual void OnDisappearing()
{
- Items.DisposeAll();
}
///
@@ -77,25 +78,45 @@ 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();
-
- var isPickingFolder = BrowserViewModel.IsPickingFolder;
- var items = await Folder.GetItemsAsync(isPickingFolder ? StorableType.Folder : StorableType.All, cancellationToken).ToArrayAsyncImpl(cancellationToken: cancellationToken);
- BrowserViewModel.Layouts.GetSorter().SortCollection(items.Where(x => !isPickingFolder || x is IFolder).Select(x => (BrowserItemViewModel)(x switch
- {
- IFile file => new FileViewModel(file, BrowserViewModel, this),
- IFolder folder => new FolderViewModel(folder, BrowserViewModel, this),
- _ => throw new ArgumentOutOfRangeException(nameof(x))
- })), Items);
+ // Cancel any listing already in flight
+ if (_listingCts is not null)
+ await _listingCts.CancelAsync();
- // Apply adaptive layout
- if (SettingsService.UserSettings.IsAdaptiveLayoutEnabled && BrowserViewModel.TransferViewModel is { IsPickingFolder: false })
- ApplyAdaptiveLayout();
+ _listingCts?.Dispose();
+ _listingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var token = _listingCts.Token;
- Logger.LogPerformance(scope, minThresholdMs: 200);
+ try
+ {
+ var scope = Logger.GetPerformanceScope();
+
+ // Enumerate before mutating the collection, so a canceled or failed
+ // enumeration leaves the current contents intact
+ var isPickingFolder = BrowserViewModel.IsPickingFolder;
+ var items = await Folder.GetItemsAsync(isPickingFolder ? StorableType.Folder : StorableType.All, token).ToArrayAsyncImpl(cancellationToken: token);
+ token.ThrowIfCancellationRequested();
+
+ SelectedItems.Clear();
+ Items.DisposeAll();
+ Items.Clear();
+
+ BrowserViewModel.Layouts.GetSorter().SortCollection(items.Where(x => !isPickingFolder || x is IFolder).Select(x => (BrowserItemViewModel)(x switch
+ {
+ IFile file => new FileViewModel(file, BrowserViewModel, this),
+ IFolder folder => new FolderViewModel(folder, BrowserViewModel, this),
+ _ => throw new ArgumentOutOfRangeException(nameof(x))
+ })), Items);
+
+ // Apply adaptive layout
+ if (SettingsService.UserSettings.IsAdaptiveLayoutEnabled && BrowserViewModel.TransferViewModel is { IsPickingFolder: false })
+ ApplyAdaptiveLayout();
+
+ Logger.LogPerformance(scope, minThresholdMs: 200);
+ }
+ catch (OperationCanceledException)
+ {
+ // A newer listing superseded this one (or the caller canceled)
+ }
}
///
@@ -116,10 +137,31 @@ protected override async Task OpenAsync(CancellationToken cancellationToken)
private void ApplyAdaptiveLayout()
{
var itemCount = Items.Count;
- var folderPercentage = 100f * Items.Count(IsFolder) / itemCount;
- var imagePercentage = 100f * Items.Count(IsImage) / itemCount;
- var mediaPercentage = 100f * Items.Count(IsMedia) / itemCount;
- var documentPercentage = 100f * Items.Count(IsDocument) / itemCount;
+ if (itemCount == 0)
+ return;
+
+ // Classify each item once
+ int folders = 0, images = 0, media = 0, documents = 0;
+ foreach (var item in Items)
+ {
+ if (item.Inner is IFolder)
+ {
+ folders++;
+ continue;
+ }
+
+ switch (FileTypeHelper.GetTypeHint(item.Inner))
+ {
+ case TypeHint.Image: images++; break;
+ case TypeHint.Media: media++; break;
+ case TypeHint.Document or TypeHint.Plaintext: documents++; break;
+ }
+ }
+
+ var folderPercentage = 100f * folders / itemCount;
+ var imagePercentage = 100f * images / itemCount;
+ var mediaPercentage = 100f * media / itemCount;
+ var documentPercentage = 100f * documents / itemCount;
var otherPercentage = 100f - (folderPercentage + imagePercentage + mediaPercentage + documentPercentage);
var galleryView = BrowserViewModel.Layouts.ViewOptions.FirstOrDefault(x => x.Id == "GalleryView");
@@ -154,18 +196,6 @@ private void ApplyAdaptiveLayout()
// ListView
BrowserViewModel.Layouts.CurrentViewOption = listView;
}
-
- static bool IsFolder(BrowserItemViewModel item)
- => item.Inner is IFolder;
-
- static bool IsImage(BrowserItemViewModel item)
- => FileTypeHelper.GetTypeHint(item.Inner) is TypeHint.Image;
-
- static bool IsMedia(BrowserItemViewModel item)
- => FileTypeHelper.GetTypeHint(item.Inner) is TypeHint.Media;
-
- static bool IsDocument(BrowserItemViewModel item)
- => FileTypeHelper.GetTypeHint(item.Inner) is TypeHint.Document or TypeHint.Plaintext;
}
}
}
From 4af3db595e8f124a86190ec6c7a9eabd6040f891 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 22:13:39 +0200
Subject: [PATCH 35/55] Dispose all views in BrowserViewModel
---
.../ViewModels/Views/Vault/BrowserViewModel.cs | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs
index ef5b7a51e..9c965cdf4 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs
@@ -93,7 +93,8 @@ public override void OnDisappearing()
TransferViewModel?.CancelCommand.Execute(null);
}
- CurrentFolder?.Dispose();
+ // Do not dispose CurrentFolder here because the page may reappear (e.g., after an
+ // overlay closes) and its items are still bound. Disposal happens in Dispose()
base.OnDisappearing();
}
@@ -400,6 +401,21 @@ await TransferViewModel.TransferAsync(galleryItems, async (item, token) =>
public void Dispose()
{
ThumbnailCache.Dispose();
+
+ // Dispose every folder accumulated in the navigation stack
+ if (InnerNavigator is INavigationService navigationService)
+ {
+ foreach (var view in navigationService.Views)
+ {
+ if (view is not FolderViewModel folderViewModel || folderViewModel == CurrentFolder)
+ continue;
+
+ folderViewModel.Items.DisposeAll();
+ folderViewModel.Items.Clear();
+ folderViewModel.Dispose();
+ }
+ }
+
CurrentFolder?.Dispose();
CurrentFolder?.Items.DisposeAll();
CurrentFolder?.Items.Clear();
From 17e6c4624827cd4d426697b09fa5b3afcd72523b Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 22:14:37 +0200
Subject: [PATCH 36/55] Fixed BrowserPage navigation stack tracking
---
.../Views/Vault/BrowserPage.xaml.cs | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml.cs b/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml.cs
index 7a99936c3..dc469d1f7 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml.cs
@@ -53,8 +53,12 @@ public async Task GoBackAsync()
if (ViewModel?.InnerNavigator is not MauiNavigationService navigationService)
return false;
+ // Already at the bottom of the navigation stack, return false
var index = navigationService.IndexInNavigation;
- if (navigationService.Views[Math.Max(--index, 0)] is not FolderViewModel folderViewModel)
+ if (index <= 0)
+ return false;
+
+ if (navigationService.Views[index - 1] is not FolderViewModel folderViewModel)
return false;
// Remove the last navigated-to breadcrumb
@@ -78,8 +82,12 @@ public async Task GoForwardAsync()
if (ViewModel?.InnerNavigator is not MauiNavigationService navigationService)
return false;
+ // Already at the top of the navigation stack, return false
var index = navigationService.IndexInNavigation;
- if (navigationService.Views[Math.Min(++index, Math.Max(navigationService.Views.Count, 0))] is not FolderViewModel folderViewModel)
+ if (index + 1 >= navigationService.Views.Count)
+ return false;
+
+ if (navigationService.Views[index + 1] is not FolderViewModel folderViewModel)
return false;
// Animate navigation
From bdb5d6b07c1ba7c6ffc48a6d1db17d6aded67192 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Thu, 2 Jul 2026 22:23:56 +0200
Subject: [PATCH 37/55] Adjust dictionary cache eviction comment
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs b/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs
index c93e37f60..0773921bb 100644
--- a/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs
+++ b/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs
@@ -67,7 +67,7 @@ public virtual bool CacheSet(TKey key, TValue value, bool skipExistingCheck = fa
lock (threadLock)
{
- // Evict the oldest entry when the capacity is reached
+ // Evict an arbitrary entry when the capacity is reached (Dictionary enumeration order is not guaranteed)
if (capacity > 0 && cache.Count >= capacity && !cache.ContainsKey(key))
{
using var enumerator = cache.Keys.GetEnumerator();
From 2bc33e42dba384e7c7bc494a0daa868172e37b5f Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 01:25:02 +0200
Subject: [PATCH 38/55] Added feedback for when entered recovery key is
incorrect
---
src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs | 4 ++--
.../SecureFolderFS.Maui/Prompts/RecoveryPrompt.cs | 12 ++++++++++++
.../SecureFolderFS.Uno/Dialogs/RecoveryDialog.xaml | 5 ++++-
.../UserControls/RecoveryControl.xaml | 7 +++++++
.../UserControls/RecoveryControl.xaml.cs | 8 ++++++++
.../Views/Overlays/RecoveryOverlayViewModel.cs | 1 +
6 files changed, 34 insertions(+), 3 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs
index 50313765e..b592f778f 100644
--- a/src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs
+++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs
@@ -1,7 +1,7 @@
-using OwlCore.Storage;
+using System.Threading.Tasks;
+using OwlCore.Storage;
using SecureFolderFS.Core.FileSystem;
using SecureFolderFS.Storage.VirtualFileSystem;
-using System.Threading.Tasks;
namespace SecureFolderFS.Core.WebDav
{
diff --git a/src/Platforms/SecureFolderFS.Maui/Prompts/RecoveryPrompt.cs b/src/Platforms/SecureFolderFS.Maui/Prompts/RecoveryPrompt.cs
index fdf2b20c9..1db9929e4 100644
--- a/src/Platforms/SecureFolderFS.Maui/Prompts/RecoveryPrompt.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Prompts/RecoveryPrompt.cs
@@ -23,9 +23,21 @@ public async Task ShowAsync()
"Confirm".ToLocalized(),
"Cancel".ToLocalized());
+ // A null result means the user dismissed the prompt. Treat this as a cancellation
+ if (ViewModel.RecoveryKey is null)
+ return Result.Failure(null);
+
var result = await ViewModel.RecoverAsync(default);
if (!result.Successful)
+ {
+ // The native prompt cannot show the error inline, so surface it explicitly
+ await page.DisplayAlertAsync(
+ ViewModel.Title,
+ ViewModel.ErrorMessage ?? "UnknownError".ToLocalized(),
+ "Close".ToLocalized());
+
return result;
+ }
return Result.Success;
}
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/RecoveryDialog.xaml b/src/Platforms/SecureFolderFS.Uno/Dialogs/RecoveryDialog.xaml
index 15fe6b289..53eb145c1 100644
--- a/src/Platforms/SecureFolderFS.Uno/Dialogs/RecoveryDialog.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/RecoveryDialog.xaml
@@ -24,5 +24,8 @@
500
-
+
diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml
index 13becd03d..10442f07e 100644
--- a/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml
@@ -26,5 +26,12 @@
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml.cs
index a93654499..194969967 100644
--- a/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml.cs
@@ -28,5 +28,13 @@ public ICommand? PasteRecoveryKeyCommand
}
public static readonly DependencyProperty PasteRecoveryKeyCommandProperty =
DependencyProperty.Register(nameof(PasteRecoveryKeyCommand), typeof(ICommand), typeof(RecoveryControl), new PropertyMetadata(null));
+
+ public string? ErrorMessage
+ {
+ get => (string?)GetValue(ErrorMessageProperty);
+ set => SetValue(ErrorMessageProperty, value);
+ }
+ public static readonly DependencyProperty ErrorMessageProperty =
+ DependencyProperty.Register(nameof(ErrorMessage), typeof(string), typeof(RecoveryControl), new PropertyMetadata(null));
}
}
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs
index 1ad629323..176b7efb9 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs
@@ -36,6 +36,7 @@ public RecoveryOverlayViewModel(IFolder vaultFolder)
public async Task RecoverAsync(CancellationToken cancellationToken)
{
+ ErrorMessage = null;
try
{
var unlockContract = await VaultManagerService.RecoverAsync(_vaultFolder, RecoveryKey ?? string.Empty, cancellationToken);
From b924a7110f6ce71040abc8f374076c91ff1dfeec Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 01:55:45 +0200
Subject: [PATCH 39/55] Surface errors in vault wizard summary pages
---
.../Modals/Wizard/SummaryWizardPage.xaml | 23 +++++++++++
.../Views/VaultWizard/SummaryWizardPage.xaml | 38 +++++++++++++------
.../Views/Wizard/SummaryWizardViewModel.cs | 14 ++++---
.../Models/Result.Message.cs | 13 +++++++
4 files changed, 71 insertions(+), 17 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/SummaryWizardPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/SummaryWizardPage.xaml
index 76b016eb9..43097aed1 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/SummaryWizardPage.xaml
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/SummaryWizardPage.xaml
@@ -33,12 +33,14 @@
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml
index 07acd2593..e5dc9d177 100644
--- a/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml
@@ -22,18 +22,34 @@
Orientation="Vertical"
Spacing="8">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+ /// Creates an instance of by combining the given base result and an additional message.
+ ///
+ /// The base result, which contains the success state or an exception.
+ /// The additional message to include in the result.
+ /// A new instance of containing the provided base result and message.
+ public static IResultWithMessage WithMessage(IResult baseResult, string message)
+ {
+ return baseResult.Exception is not null
+ ? new MessageResult(baseResult.Exception, message)
+ : new MessageResult(baseResult.Successful, message);
+ }
}
///
From 108d58e1726e6f4e5b15d09be4805175758d123e Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 02:06:50 +0200
Subject: [PATCH 40/55] Improved error handling for customizing vault icons
---
.../Views/VaultWizard/SummaryWizardPage.xaml | 2 +-
.../VaultList/VaultListItemViewModel.cs | 41 +++++++++++--------
2 files changed, 25 insertions(+), 18 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml
index e5dc9d177..96c98cd9e 100644
--- a/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml
@@ -23,7 +23,7 @@
Spacing="8">
-
+
()?.LogError(ex, "Failed to set the vault folder icon.");
+ }
break;
}
@@ -155,7 +162,7 @@ private async Task RevealFolderAsync(CancellationToken cancellationToken)
[RelayCommand]
private async Task CreateShortcutAsync(CancellationToken cancellationToken)
{
- if (VaultViewModel.VaultModel.VaultFolder is not { } vaultFolder)
+ if (VaultViewModel.VaultModel.VaultFolder is null)
return;
// Check Iap Plus requirement
@@ -172,7 +179,7 @@ private async Task CreateShortcutAsync(CancellationToken cancellationToken)
var filter = new Dictionary
{
- { "SecureFolderFS Vault", VaultService.ShortcutFileExtension }
+ { $"{"Vault".ToLocalized()} ({nameof(SecureFolderFS)})", VaultService.ShortcutFileExtension }
};
await FileExplorerService.SaveFileAsync(suggestedName, dataStream, filter, cancellationToken);
From 4b0e70c35cdd92c5476031e867d80cfc6fd23f55 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 02:33:38 +0200
Subject: [PATCH 41/55] Replaced background Thread with loop Task in
WebDavWrapper
---
.../Helpers/PortHelpers.cs | 76 +++++++++++++++++--
.../WebDavWrapper.cs | 32 ++++----
2 files changed, 87 insertions(+), 21 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs b/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs
index 4654f3639..6e2f04cb4 100644
--- a/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs
+++ b/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs
@@ -1,36 +1,98 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Net;
using System.Net.NetworkInformation;
+using System.Net.Sockets;
namespace SecureFolderFS.Core.WebDav.Helpers
{
internal static class PortHelpers
{
+ ///
+ /// Checks if the specified is available for binding by attempting
+ /// to listen on it or verifying it is not in use.
+ ///
+ /// The port number to check for availability.
+ ///
+ /// true if the port is available for use; otherwise, false.
+ ///
public static bool IsPortAvailable(int port)
{
- return !GetUnavailablePorts().Contains(port);
+ if (OperatingSystem.IsAndroid())
+ return CanBindPort(port);
+
+ try
+ {
+ return !GetUnavailablePorts().Contains(port);
+ }
+ catch (Exception)
+ {
+ // Enumeration may be unsupported on this platform
+ return CanBindPort(port);
+ }
}
+ ///
+ /// Finds and returns the next available network port starting from the specified .
+ ///
+ /// The port number from which to start searching for an available port.
+ /// The next available port number.
public static int GetNextAvailablePort(int startingPort)
{
- var unavailablePorts = GetUnavailablePorts().ToList();
+ if (!OperatingSystem.IsAndroid())
+ {
+ try
+ {
+ var unavailablePorts = GetUnavailablePorts().ToHashSet();
+ for (var i = startingPort; i <= ushort.MaxValue; i++)
+ {
+ if (!unavailablePorts.Contains(i))
+ return i;
+ }
+
+ throw new InvalidOperationException("No available ports.");
+ }
+ catch (Exception ex) when (ex is not InvalidOperationException)
+ {
+ // Enumeration is unsupported on this platform
+ }
+ }
+
for (var i = startingPort; i <= ushort.MaxValue; i++)
- if (!unavailablePorts.Contains(i))
+ {
+ if (CanBindPort(i))
return i;
+ }
throw new InvalidOperationException("No available ports.");
}
- private static IEnumerable GetUnavailablePorts()
+ ///
+ /// Determines whether can be bound by attempting to listen on it.
+ /// Works on every platform, including those where enumeration is unavailable (e.g. Android).
+ ///
+ private static bool CanBindPort(int port)
{
- if (OperatingSystem.IsAndroid())
- return Enumerable.Empty(); // TODO(u android)
+ try
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, port);
+ listener.Start();
+ listener.Stop();
+ return true;
+ }
+ catch (SocketException)
+ {
+ return false;
+ }
+ }
+ private static IEnumerable GetUnavailablePorts()
+ {
var properties = IPGlobalProperties.GetIPGlobalProperties();
return properties.GetActiveTcpConnections().Select(x => x.LocalEndPoint.Port)
.Concat(properties.GetActiveTcpListeners().Select(x => x.Port))
.Concat(properties.GetActiveUdpListeners().Select(x => x.Port));
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs
index 81169e708..e1df89d6f 100644
--- a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs
+++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs
@@ -12,7 +12,7 @@ public sealed class WebDavWrapper
{
private const int ERROR_OPERATION_ABORTED = 995;
- private Thread? _fsThead;
+ private Task? _acceptLoopTask;
private readonly HttpListener _httpListener;
private readonly IRequestDispatcher _requestDispatcher;
private readonly CancellationTokenSource _fileSystemCts;
@@ -28,23 +28,14 @@ public WebDavWrapper(HttpListener httpListener, IRequestDispatcher requestDispat
public void StartFileSystem()
{
- var ts = new ThreadStart(async () => await EnsureFileSystemAsync());
- _fsThead = new Thread(ts);
- _fsThead.Start();
+ // The accept loop is fully asynchronous and I/O-bound (each request is dispatched onto the thread pool).
+ // Running it as a background task keeps the UI responsive without occupying a thread
+ _acceptLoopTask = Task.Run(EnsureFileSystemAsync);
}
private async Task EnsureFileSystemAsync()
{
- try
- {
- _httpListener.Start();
- }
- catch (Exception ex)
- {
- _requestDispatcher.Logger?.LogError(ex, "Failed to start the WebDAV HTTP listener.");
- return;
- }
-
+ // The caller starts the listener before mounting, so a bind failure has already surfaced
while (!_fileSystemCts.IsCancellationRequested)
{
HttpListenerContext httpListenerContext;
@@ -85,6 +76,19 @@ public async Task CloseFileSystemAsync()
_httpListener.Close();
await _fileSystemCts.CancelAsync();
+ // Wait for the accept loop to observe the closed listener and unwind
+ if (_acceptLoopTask is not null)
+ {
+ try
+ {
+ await _acceptLoopTask;
+ }
+ catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
+ {
+ // Expected during teardown
+ }
+ }
+
if (_mountPath is not null)
DriveMappingHelpers.DisconnectNetworkDrive(_mountPath, true);
From ca625e79027dea040b790783881b34229dc11d7b Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 02:40:33 +0200
Subject: [PATCH 42/55] Added safety restraints for loading types from disk
---
.../AppModels/Database/BatchDatabaseModel.cs | 29 ++++++----
.../AppModels/Database/SafeTypeResolver.cs | 55 +++++++++++++++++++
.../Database/SingleFileDatabaseModel.cs | 6 +-
3 files changed, 77 insertions(+), 13 deletions(-)
create mode 100644 src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SafeTypeResolver.cs
diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs
index 4bbc97d4d..dd19ab47a 100644
--- a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs
@@ -1,12 +1,13 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
using OwlCore.Storage;
+using SecureFolderFS.Shared;
using SecureFolderFS.Shared.ComponentModel;
using SecureFolderFS.Shared.Extensions;
using SecureFolderFS.Storage.Extensions;
@@ -86,9 +87,10 @@ public override async Task WipeAsync(CancellationToken cancellationToken = defau
///
public override async Task InitAsync(CancellationToken cancellationToken = default)
{
+ // Acquire before entering the try so a canceled wait does not release a permit we never obtained
+ await storageSemaphore.WaitAsync(cancellationToken);
try
{
- await storageSemaphore.WaitAsync(cancellationToken);
await EnsureSettingsFolderAsync(cancellationToken);
_ = _databaseFolder ?? throw new InvalidOperationException("The database folder was not properly initialized.");
@@ -110,8 +112,8 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau
if (string.IsNullOrEmpty(typeString))
continue;
- // Get original type
- var originalType = Type.GetType(typeString);
+ // Get original type, restricting resolution to trusted assemblies
+ var originalType = SafeTypeResolver.Resolve(typeString);
if (originalType is null)
continue;
@@ -124,9 +126,8 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau
}
catch (Exception ex)
{
- // TODO: Re-throw exceptions in some cases?
- _ = ex;
- Debugger.Break();
+ // A single corrupt setting must not prevent the remaining ones from loading
+ DI.OptionalService()?.LogWarning(ex, "Failed to load the setting '{SettingName}'.", dataFile.Name);
}
}
}
@@ -139,9 +140,11 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau
///
public override async Task SaveAsync(CancellationToken cancellationToken = default)
{
+ // Acquire before entering the try so a canceled wait does not release a permit we never obtained
+ await storageSemaphore.WaitAsync(cancellationToken);
+ List? failures = null;
try
{
- await storageSemaphore.WaitAsync(cancellationToken);
await EnsureSettingsFolderAsync(cancellationToken);
_ = _databaseFolder ?? throw new InvalidOperationException("The database folder was not properly initialized.");
@@ -192,9 +195,10 @@ public override async Task SaveAsync(CancellationToken cancellationToken = defau
}
catch (Exception ex)
{
- // TODO: Re-throw exceptions in some cases?
- _ = ex;
- Debugger.Break();
+ // Continue persisting the remaining settings, but remember the failure so the
+ // caller can observe that the save did not fully succeed
+ DI.OptionalService()?.LogError(ex, "Failed to save the setting '{SettingName}'.", item.Key);
+ (failures ??= new()).Add(ex);
}
}
}
@@ -202,6 +206,9 @@ public override async Task SaveAsync(CancellationToken cancellationToken = defau
{
_ = storageSemaphore.Release();
}
+
+ if (failures is not null)
+ throw new AggregateException("One or more settings could not be saved.", failures);
}
private async Task EnsureSettingsFolderAsync(CancellationToken cancellationToken)
diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SafeTypeResolver.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SafeTypeResolver.cs
new file mode 100644
index 000000000..6f52d5e05
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SafeTypeResolver.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Linq;
+
+namespace SecureFolderFS.Sdk.AppModels.Database
+{
+ ///
+ /// Resolves instances from persisted type strings while restricting resolution
+ /// to a set of trusted assemblies.
+ ///
+ ///
+ /// The type name originates from an on-disk settings file. Resolving arbitrary assembly-qualified names would
+ /// let anyone able to write to the settings folder coerce the app into loading unexpected types. Only the
+ /// application's own assemblies and the core framework are considered safe.
+ ///
+ internal static class SafeTypeResolver
+ {
+ private static readonly string[] AllowedAssemblyPrefixes =
+ [
+ nameof(SecureFolderFS),
+ nameof(System),
+ "mscorlib",
+ "netstandard"
+ ];
+
+ ///
+ /// Resolves the described by if it belongs to a trusted assembly.
+ ///
+ /// The assembly-qualified or namespace-qualified type name.
+ /// The resolved , or null if the type is unknown or resides in an untrusted assembly.
+ public static Type? Resolve(string typeName)
+ {
+ if (string.IsNullOrEmpty(typeName))
+ return null;
+
+ var type = Type.GetType(typeName, throwOnError: false);
+ if (type is null)
+ return null;
+
+ return IsTrusted(type) ? type : null;
+ }
+
+ private static bool IsTrusted(Type type)
+ {
+ // Validate the type itself and every generic argument
+ var assemblyName = type.Assembly.GetName().Name;
+ if (assemblyName is null || !AllowedAssemblyPrefixes.Any(prefix => assemblyName.StartsWith(prefix, StringComparison.Ordinal)))
+ return false;
+
+ if (type.IsGenericType)
+ return type.GetGenericArguments().All(IsTrusted);
+
+ return true;
+ }
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SingleFileDatabaseModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SingleFileDatabaseModel.cs
index 851ae9038..40ebbd4a8 100644
--- a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SingleFileDatabaseModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SingleFileDatabaseModel.cs
@@ -81,9 +81,10 @@ public override async Task WipeAsync(CancellationToken cancellationToken = defau
///
public override async Task InitAsync(CancellationToken cancellationToken = default)
{
+ // Acquire before entering the try so a canceled wait does not release a permit we never obtained
+ await storageSemaphore.WaitAsync(cancellationToken);
try
{
- await storageSemaphore.WaitAsync(cancellationToken);
await EnsureSettingsFileAsync(cancellationToken);
_ = _databaseFile ?? throw new InvalidOperationException("The database file was not properly initialized.");
@@ -117,9 +118,10 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau
///
public override async Task SaveAsync(CancellationToken cancellationToken = default)
{
+ // Acquire before entering the try so a canceled wait does not release a permit we never obtained
+ await storageSemaphore.WaitAsync(cancellationToken);
try
{
- await storageSemaphore.WaitAsync(cancellationToken);
await EnsureSettingsFileAsync(cancellationToken);
_ = _databaseFile ?? throw new InvalidOperationException("The database file was not properly initialized.");
From a1bcf93c7a980c569eb7daef1ef6f2a2a88af03e Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 02:47:04 +0200
Subject: [PATCH 43/55] Avoid starting a dead WebDav server edge-case
---
.../WebDavFileSystem.cs | 45 ++++++++++++++++---
1 file changed, 39 insertions(+), 6 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs
index e335aae6a..cd982fc1d 100644
--- a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs
+++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs
@@ -22,6 +22,8 @@ namespace SecureFolderFS.Core.WebDav
///
public abstract class WebDavFileSystem : IFileSystemInfo
{
+ private const int MAX_LISTENER_START_ATTEMPTS = 10;
+
///
public string Id { get; } = Constants.FileSystem.FS_ID;
@@ -49,13 +51,12 @@ public virtual async Task MountAsync(IFolder folder, IDisposable unloc
if (!PortHelpers.IsPortAvailable(webDavOptions.Port))
webDavOptions.SetPortInternal(PortHelpers.GetNextAvailablePort(webDavOptions.Port));
- var prefix = $"{webDavOptions.Protocol}://{webDavOptions.Domain}:{webDavOptions.Port}/";
- var httpListener = new HttpListener();
-
- httpListener.Prefixes.Add(prefix);
- httpListener.AuthenticationSchemes = AuthenticationSchemes.Anonymous;
+ // Start the listener up-front (self-healing onto another port if the bind fails) so a genuine
+ // failure surfaces to the caller and fails the unlocking operation, instead of leaving a silently-dead server.
+ // Starting here also fixes webDavOptions.Port to the actually bound port before the platform
+ // implementation derives the mount URL from it.
+ var httpListener = StartListener(webDavOptions);
- //var store = new EncryptingDiskStore(specifics.ContentFolder.Id, specifics, !specifics.Options.IsReadOnly);
var rootFolder = new CryptoFolder(specifics.ContentFolder.Id, specifics.ContentFolder, specifics);
var store = new BackedDavStore(rootFolder, !specifics.Options.IsReadOnly);
var dispatcher = new WebDavDispatcher(new RootDiskStore(specifics.Options.VolumeName, store), new RequestHandlerProvider(), null);
@@ -68,6 +69,38 @@ public virtual async Task MountAsync(IFolder folder, IDisposable unloc
cancellationToken);
}
+ ///
+ /// Creates and starts an for the configured WebDAV endpoint, with a fallback to
+ /// the next available port if the bind fails (e.g., the port was taken between the availability check and the bind).
+ ///
+ /// The WebDAV options. The is updated to the actually bound port.
+ /// A started bound to the resolved port.
+ private static HttpListener StartListener(WebDavOptions options)
+ {
+ HttpListenerException? lastException = null;
+ for (var attempt = 0; attempt < MAX_LISTENER_START_ATTEMPTS; attempt++)
+ {
+ var listener = new HttpListener();
+ listener.Prefixes.Add($"{options.Protocol}://{options.Domain}:{options.Port}/");
+ listener.AuthenticationSchemes = AuthenticationSchemes.Anonymous;
+
+ try
+ {
+ listener.Start();
+ return listener;
+ }
+ catch (HttpListenerException ex)
+ {
+ // The port became unavailable between the check and the bind, proceed with self-heal onto the next free port
+ lastException = ex;
+ listener.Close();
+ options.SetPortInternal(PortHelpers.GetNextAvailablePort(options.Port + 1));
+ }
+ }
+
+ throw lastException ?? new HttpListenerException();
+ }
+
///
public abstract Task GetVolumeNameAsync(string candidateName, CancellationToken cancellationToken = default);
From 14dc8477bbcbb9310c9a57caaa92ddf1eb099a3c Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 15:51:04 +0200
Subject: [PATCH 44/55] Support error reporting in TransferControl
---
.../Browser/BrowserControl.DragDrop.xaml.cs | 26 +++---
.../UserControls/Browser/TransferControl.xaml | 30 +++++--
.../Browser/TransferControl.xaml.cs | 25 ++++++
.../ViewModels/GDriveAccountViewModel.cs | 21 +++--
.../AppModels/VaultCollectionModel.cs | 5 +-
.../Extensions/TransferExtensions.cs | 57 ++++++++++---
.../Controls/Transfer/TransferViewModel.cs | 81 +++++++++++++++++++
7 files changed, 212 insertions(+), 33 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs
index 66e262f8f..5750951b8 100644
--- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs
@@ -209,9 +209,9 @@ private static async Task MoveItemToFolderAsync(BrowserItemViewModel draggedItem
transferViewModel.TransferType = TransferType.Move;
using var cts = transferViewModel.GetCancellation();
- // Ensure the destination has content already loaded
+ // Ensure the destination has content already loaded before collision checks
if (destinationViewModel.Items.IsEmpty())
- _ = destinationViewModel.ListContentsAsync(cts.Token);
+ await destinationViewModel.ListContentsAsync(cts.Token);
await transferViewModel.TransferAsync([ itemToMove ], async (item, reporter, token) =>
{
@@ -233,10 +233,13 @@ await transferViewModel.TransferAsync([ itemToMove ], async (item, reporter, tok
}, browserViewModel.Layouts.GetSorter());
}, cts.Token);
}
- catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
+ catch (OperationCanceledException)
{
- _ = ex;
- // TODO: Report error
+ // Cancellation is user intent - nothing to report
+ }
+ catch (Exception)
+ {
+ await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized());
}
finally
{
@@ -426,9 +429,9 @@ private static async Task CopyExternalFilesToFolderAsync(DropEventArgs dropEvent
transferViewModel.TransferType = TransferType.Copy;
using var cts = transferViewModel.GetCancellation();
- // Ensure the destination has content already loaded
+ // Ensure the destination has content already loaded before collision checks
if (destinationViewModel.Items.IsEmpty())
- _ = destinationViewModel.ListContentsAsync(cts.Token);
+ await destinationViewModel.ListContentsAsync(cts.Token);
await transferViewModel.TransferAsync(itemsToProcess, async (item, reporter, token) =>
{
@@ -463,10 +466,13 @@ await transferViewModel.TransferAsync(itemsToProcess, async (item, reporter, tok
}
}, x => x.Name, cts.Token);
}
- catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
+ catch (OperationCanceledException)
+ {
+ // Cancellation is user intent - nothing to report
+ }
+ catch (Exception)
{
- _ = ex;
- // TODO: Report error
+ await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized());
}
finally
{
diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml
index 56d597636..2fd8d2b34 100644
--- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml
+++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml
@@ -5,6 +5,9 @@
xmlns:av="clr-namespace:Xe.AcrylicView;assembly=Xe.AcrylicView"
xmlns:av2="clr-namespace:Xe.AcrylicView.Controls;assembly=Xe.AcrylicView"
xmlns:local="clr-namespace:SecureFolderFS.Maui.UserControls.Browser"
+ 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:uc="clr-namespace:SecureFolderFS.Maui.UserControls"
x:Name="ThisControl">
@@ -54,11 +57,26 @@
WidthRequest="32" />
-
+
+
+
+
+
@@ -67,7 +85,7 @@
Padding="16,8"
Command="{Binding PrimaryCommand, Mode=OneWay}"
CornerRadius="10"
- IsVisible="{Binding IsProgressing, Mode=OneWay, Converter={StaticResource BoolInvertConverter}}"
+ IsVisible="{Binding IsConfirmShown, Mode=OneWay}"
Style="{StaticResource AccentButtonStyle}"
Text="{Binding PrimaryButtonText, Mode=OneWay}" />
diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs
index ca3eeb025..a8e5c47cd 100644
--- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs
@@ -21,6 +21,15 @@ private async void Panel_PanUpdated(object? sender, PanUpdatedEventArgs e)
if (_isDismissing)
return;
+ // Swipe-down dismiss cancels the operation. Disallow it for non-cancellable operations
+ if (!CanCancel)
+ {
+ if (RootPanel.TranslationY != 0d)
+ await RootPanel.TranslateToAsync(0, 0, 250U, Easing.SpringOut);
+
+ return;
+ }
+
switch (e.StatusType)
{
case GestureStatus.Running:
@@ -121,6 +130,22 @@ public bool IsProgressing
public static readonly BindableProperty IsProgressingProperty =
BindableProperty.Create(nameof(IsProgressing), typeof(bool), typeof(TransferControl), false);
+ public bool IsConfirmShown
+ {
+ get => (bool)GetValue(IsConfirmShownProperty);
+ set => SetValue(IsConfirmShownProperty, value);
+ }
+ public static readonly BindableProperty IsConfirmShownProperty =
+ BindableProperty.Create(nameof(IsConfirmShown), typeof(bool), typeof(TransferControl), false);
+
+ public bool IsError
+ {
+ get => (bool)GetValue(IsErrorProperty);
+ set => SetValue(IsErrorProperty, value);
+ }
+ public static readonly BindableProperty IsErrorProperty =
+ BindableProperty.Create(nameof(IsError), typeof(bool), typeof(TransferControl), false);
+
public string? Title
{
get => (string?)GetValue(TitleProperty);
diff --git a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs
index 14a45a111..3d42a030d 100644
--- a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs
@@ -6,6 +6,7 @@
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.Logging;
using Google;
using Google.Apis.Auth.OAuth2;
using Google.Apis.Drive.v3;
@@ -17,6 +18,7 @@
using SecureFolderFS.Sdk.GoogleDrive.AppModels;
using SecureFolderFS.Sdk.GoogleDrive.DataModels;
using SecureFolderFS.Sdk.GoogleDrive.Storage;
+using SecureFolderFS.Shared;
using SecureFolderFS.Shared.Api;
using SecureFolderFS.Shared.ComponentModel;
using SecureFolderFS.Shared.Extensions;
@@ -222,8 +224,13 @@ private async Task ConnectAccountPromptAsync(CancellationToken cancellationToken
await ConnectFromUserInputAsync(cancellationToken);
// User info is already fetched in ConnectFromUserInputAsync
}
- catch (Exception)
+ catch (OperationCanceledException)
+ {
+ // The user dismissed the authorization prompt
+ }
+ catch (Exception ex)
{
+ DI.OptionalService()?.LogError(ex, "Failed to connect the Google Drive account.");
UserDisplayName = null;
UserPhotoUri = null;
UserEmail = null;
@@ -253,14 +260,18 @@ private async Task FetchUserInfoAsync(CancellationToken cancellationToken)
}
catch (Exception ex)
{
- // TODO: Log or handle the error appropriately
- // For now, just set default values
+ // Fall back to a best-effort display name while keeping the account usable
UserDisplayName = _credential?.UserId ?? "Unknown user";
UserEmail = null;
- // Re-throw if it's a critical error
- if (ex is GoogleApiException apiEx && apiEx.HttpStatusCode == HttpStatusCode.Unauthorized)
+ // Surface an expired/revoked token so the caller can trigger re-authentication and update the stored config
+ if (ex is GoogleApiException { HttpStatusCode: HttpStatusCode.Unauthorized })
+ {
+ DI.OptionalService()?.LogWarning(ex, "Google Drive authorization expired; re-authentication is required.");
throw;
+ }
+
+ DI.OptionalService()?.LogError(ex, "Failed to fetch Google Drive user information.");
}
}
diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs
index a031ff7bb..dc1a4ebf6 100644
--- a/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs
@@ -5,6 +5,7 @@
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
+using Microsoft.Extensions.Logging;
using OwlCore.Storage;
using SecureFolderFS.Sdk.Attributes;
using SecureFolderFS.Sdk.DataModels;
@@ -100,8 +101,8 @@ public async Task InitAsync(CancellationToken cancellationToken = default)
}
catch (Exception ex)
{
- _ = ex;
- continue;
+ // A single unreadable vault entry must not prevent the rest of the list from loading
+ DI.OptionalService()?.LogWarning(ex, "Skipped a vault entry that could not be loaded.");
}
}
}
diff --git a/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs b/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs
index d9d2f5684..25b303519 100644
--- a/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OwlCore.Storage;
@@ -19,6 +18,10 @@ public static bool IsPickingItems(this TransferViewModel transferViewModel)
public static async Task HideAsync(this TransferViewModel transferViewModel)
{
+ // An error banner is in control of its own dismissal (see TransferViewModel.ReportErrorAsync)
+ if (transferViewModel.IsErrorVisible)
+ return;
+
transferViewModel.IsPickingFolder = false;
transferViewModel.IsVisible = false;
await Task.Delay(350);
@@ -43,6 +46,7 @@ public static async Task TransferAsync(
CancellationToken cancellationToken = default)
{
var collection = items.ToOrAsCollection();
+ transferViewModel.ClearError();
transferViewModel.IsProgressing = true;
transferViewModel.IsVisible = true;
transferViewModel.Report(new(0, collection.Count, collection.Count));
@@ -53,18 +57,34 @@ public static async Task TransferAsync(
transferViewModel.Report(new(counter, 0, x.Name));
});
- for (var i = 0; i < collection.Count; i++)
+ var failedCount = 0;
+ var index = 0;
+ foreach (var item in collection)
{
cancellationToken.ThrowIfCancellationRequested();
+ transferViewModel.Report(new(index++, collection.Count, itemName(item)));
- var item = collection.ElementAt(i);
- transferViewModel.Report(new(i, collection.Count, itemName(item)));
- await callback(item, reporter, cancellationToken);
+ try
+ {
+ await callback(item, reporter, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ // A failed item must not abandon the remaining ones
+ failedCount++;
+ }
}
transferViewModel.Report(new(collection.Count, collection.Count, string.Empty));
await Task.Delay(1000, CancellationToken.None);
await transferViewModel.HideAsync();
+
+ if (failedCount > 0)
+ await transferViewModel.ReportErrorAsync("TransferItemsFailedPlural".ToLocalized(failedCount));
}
public static async Task TransferAsync(
@@ -85,22 +105,39 @@ public static async Task TransferAsync(
CancellationToken cancellationToken = default)
{
var collection = items.ToOrAsCollection();
+ transferViewModel.ClearError();
transferViewModel.IsProgressing = true;
transferViewModel.IsVisible = true;
transferViewModel.Report(new(0, collection.Count, collection.Count));
- for (var i = 0; i < collection.Count; i++)
+ var failedCount = 0;
+ var index = 0;
+ foreach (var item in collection)
{
cancellationToken.ThrowIfCancellationRequested();
+ transferViewModel.Report(new(index++, collection.Count, itemName(item)));
- var item = collection.ElementAt(i);
- transferViewModel.Report(new(i, collection.Count, itemName(item)));
- await callback(item, cancellationToken);
+ try
+ {
+ await callback(item, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ // A failed item must not abandon the remaining ones
+ failedCount++;
+ }
}
transferViewModel.Report(new(collection.Count, collection.Count, string.Empty));
await Task.Delay(1000, CancellationToken.None);
await transferViewModel.HideAsync();
+
+ if (failedCount > 0)
+ await transferViewModel.ReportErrorAsync("TransferItemsFailedPlural".ToLocalized(failedCount));
}
public static async Task PerformOperationAsync(this TransferViewModel transferViewModel, Func operation, CancellationToken cancellationToken = default)
@@ -132,7 +169,7 @@ public static async Task PerformOperationAsync(this TransferViewModel transferVi
if (uiShown)
{
transferViewModel.Title = "TransferDone".ToLocalized();
- await Task.Delay(300, showUiCts.Token); // Allow user to see the "Done" message
+ await Task.Delay(300, CancellationToken.None); // Allow user to see the "Done" message
}
}
catch (OperationCanceledException)
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs
index 8f693ed07..00b98dd98 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs
@@ -19,16 +19,20 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Transfer
[Bindable(true)]
public sealed partial class TransferViewModel : ObservableObject, IViewable, IProgress, IFolderPicker
{
+ private const int ERROR_DISPLAY_DURATION_MS = 5000;
+
private readonly BrowserViewModel _browserViewModel;
private readonly SynchronizationContext? _synchronizationContext;
private TaskCompletionSource? _tcs;
private CancellationTokenSource? _cts;
+ private CancellationTokenSource? _errorCts;
[ObservableProperty] private string? _Title;
[ObservableProperty] private bool _CanCancel;
[ObservableProperty] private bool _IsVisible;
[ObservableProperty] private bool _IsProgressing;
[ObservableProperty] private bool _IsPickingFolder;
+ [ObservableProperty] private bool _IsErrorVisible;
[ObservableProperty] private TransferType _TransferType;
public TransferViewModel(BrowserViewModel browserViewModel)
@@ -56,8 +60,76 @@ public void Report(TotalProgress value)
ReportCore(value);
}
+ ///
+ /// Shows an indeterminate progress state, e.g. while item sizes are being calculated before a transfer.
+ ///
+ /// The title to display while the operation is running.
+ public void ShowIndeterminate(string title)
+ {
+ ClearError();
+ Title = title;
+ IsProgressing = true;
+ IsVisible = true;
+ }
+
+ ///
+ /// Dismisses any operation UI and shows as a transient error banner.
+ /// The banner disappears on its own or when dismissed by the user.
+ ///
+ /// The error message to display.
+ /// A that completes once the banner is shown.
+ public async Task ReportErrorAsync(string message)
+ {
+ ClearError();
+ _errorCts = new();
+ var token = _errorCts.Token;
+
+ // Finish and dismiss the current operation UI before revealing the error
+ if (IsVisible)
+ await this.HideAsync();
+
+ if (token.IsCancellationRequested)
+ return;
+
+ Title = message;
+ IsErrorVisible = true;
+ IsProgressing = false;
+ CanCancel = true;
+ IsVisible = true;
+
+ _ = DismissErrorLaterAsync(token);
+ }
+
+ private async Task DismissErrorLaterAsync(CancellationToken token)
+ {
+ try
+ {
+ await Task.Delay(ERROR_DISPLAY_DURATION_MS, token);
+ IsErrorVisible = false;
+ await this.HideAsync();
+ }
+ catch (OperationCanceledException)
+ {
+ // A newer operation or an explicit dismissal took over the control
+ }
+ }
+
+ ///
+ /// Clears the error state, if any, allowing the control to display a new operation.
+ ///
+ public void ClearError()
+ {
+ _errorCts?.TryCancel();
+ _errorCts?.Dispose();
+ _errorCts = null;
+ IsErrorVisible = false;
+ }
+
private void ReportCore(TotalProgress value)
{
+ if (IsErrorVisible)
+ return;
+
if (value.Achieved >= value.Total && value.Total > 0)
{
Title = "TransferDone".ToLocalized();
@@ -83,6 +155,7 @@ object GetInterpolation()
public CancellationTokenSource GetCancellation(CancellationToken? linkToken = null)
{
+ ClearError();
_cts?.Dispose();
_cts = linkToken is not null
? CancellationTokenSource.CreateLinkedTokenSource(linkToken.Value)
@@ -132,6 +205,14 @@ private void Confirm()
[RelayCommand]
private async Task CancelAsync()
{
+ if (IsErrorVisible)
+ {
+ // Dismiss the error banner since there is no operation left to cancel
+ ClearError();
+ await this.HideAsync();
+ return;
+ }
+
if (_tcs is not null)
{
_tcs.TrySetCanceled(CancellationToken.None);
From a8b8e90d27bbc9156de4e248c9e9d344619ead90 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 16:15:48 +0200
Subject: [PATCH 45/55] Inform about folder enumeration failure in
FolderViewModel
---
.../SecureFolderFS.Uno/UserControls/RecoveryControl.xaml | 1 +
.../Controls/Storage/Browser/FolderViewModel.cs | 8 ++++++++
2 files changed, 9 insertions(+)
diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml
index 10442f07e..5c20ce7b4 100644
--- a/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml
@@ -14,6 +14,7 @@
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 b673cd320..7e1d614a0 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs
@@ -7,6 +7,7 @@
using Microsoft.Extensions.Logging;
using OwlCore.Storage;
using SecureFolderFS.Sdk.Attributes;
+using SecureFolderFS.Sdk.Extensions;
using SecureFolderFS.Sdk.Services;
using SecureFolderFS.Sdk.ViewModels.Views.Vault;
using SecureFolderFS.Shared;
@@ -117,6 +118,13 @@ public async Task ListContentsAsync(CancellationToken cancellationToken = defaul
{
// A newer listing superseded this one (or the caller canceled)
}
+ catch (Exception ex)
+ {
+ // Only inform the user that the refresh failed and leave existing contents intact
+ Logger.LogError(ex, "Failed to list the contents of a folder.");
+ if (BrowserViewModel.TransferViewModel is { } transferViewModel)
+ await transferViewModel.ReportErrorAsync("FolderLoadFailed".ToLocalized());
+ }
}
///
From cc5de11880b540ee6188e939e55582cbc5288804 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 16:16:15 +0200
Subject: [PATCH 46/55] Update BrowserPage.xaml
---
src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml
index 5a56737f4..6d413dada 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml
@@ -103,6 +103,8 @@
Grid.RowSpan="2"
CanCancel="{Binding ViewModel.TransferViewModel.CanCancel, Mode=OneWay}"
CancelCommand="{Binding ViewModel.TransferViewModel.CancelCommand, Mode=OneWay}"
+ IsConfirmShown="{Binding ViewModel.TransferViewModel.IsPickingFolder, Mode=OneWay}"
+ IsError="{Binding ViewModel.TransferViewModel.IsErrorVisible, Mode=OneWay}"
IsProgressing="{Binding ViewModel.TransferViewModel.IsProgressing, Mode=OneWay}"
IsShown="{Binding ViewModel.TransferViewModel.IsVisible, Mode=OneWay}"
PrimaryButtonText="{Binding ViewModel.TransferViewModel.TransferType, Mode=OneWay, Converter={StaticResource TransferTypeToOperationStringConverter}}"
From 4d320305f1928432692eb0c57bcf48dbc4f32c90 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 16:30:55 +0200
Subject: [PATCH 47/55] Added error reporting for BrowserViewModel and
FileViewModel
---
.../Browser/BrowserControl.DragDrop.xaml.cs | 4 +-
.../Controls/Storage/Browser/FileViewModel.cs | 70 ++++----
.../Views/Vault/BrowserViewModel.cs | 157 ++++++++++--------
3 files changed, 135 insertions(+), 96 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs
index 5750951b8..ee6459f44 100644
--- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs
@@ -235,7 +235,7 @@ await transferViewModel.TransferAsync([ itemToMove ], async (item, reporter, tok
}
catch (OperationCanceledException)
{
- // Cancellation is user intent - nothing to report
+ // Cancellation, nothing to report
}
catch (Exception)
{
@@ -468,7 +468,7 @@ await transferViewModel.TransferAsync(itemsToProcess, async (item, reporter, tok
}
catch (OperationCanceledException)
{
- // Cancellation is user intent - nothing to report
+ // Cancellation, nothing to report
}
catch (Exception)
{
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 35adda17f..b315f7a40 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs
@@ -136,41 +136,53 @@ protected override async Task OpenAsync(CancellationToken cancellationToken)
if (BrowserViewModel.TransferViewModel?.IsPickingItems() ?? false)
return;
- using var viewModel = new PreviewerOverlayViewModel(this, ParentFolder);
- await viewModel.InitAsync(cancellationToken);
- await OverlayService.ShowAsync(viewModel);
-
- if (BrowserViewModel.Options.IsReadOnly)
- return;
-
- if (viewModel.PreviewerViewModel is IChangeTracker { WasModified: true } and IPersistable persistable)
+ try
{
- var messageOverlay = new MessageOverlayViewModel()
- {
- Title = "UnsavedChanges".ToLocalized(),
- Message = "UnsavedChangesDescription".ToLocalized(),
- PrimaryText = "Save".ToLocalized(),
- SecondaryText = "Cancel".ToLocalized()
- };
-
- await Task.Delay(700);
- var result = await OverlayService.ShowAsync(messageOverlay);
- if (!result.Positive())
+ using var viewModel = new PreviewerOverlayViewModel(this, ParentFolder);
+ await viewModel.InitAsync(cancellationToken);
+ await OverlayService.ShowAsync(viewModel);
+
+ if (BrowserViewModel.Options.IsReadOnly)
return;
- if (BrowserViewModel.TransferViewModel is { } transferViewModel)
+ if (viewModel.PreviewerViewModel is IChangeTracker { WasModified: true } and IPersistable persistable)
{
- transferViewModel.TransferType = TransferType.Save;
- using var saveCancellation = transferViewModel.GetCancellation();
- await transferViewModel.PerformOperationAsync(async ct =>
+ var messageOverlay = new MessageOverlayViewModel()
+ {
+ Title = "UnsavedChanges".ToLocalized(),
+ Message = "UnsavedChangesDescription".ToLocalized(),
+ PrimaryText = "Save".ToLocalized(),
+ SecondaryText = "Cancel".ToLocalized()
+ };
+
+ await Task.Delay(700, CancellationToken.None);
+ var result = await OverlayService.ShowAsync(messageOverlay);
+ if (!result.Positive())
+ return;
+
+ if (BrowserViewModel.TransferViewModel is { } transferViewModel)
{
- await persistable.SaveAsync(ct);
- }, saveCancellation.Token);
+ transferViewModel.TransferType = TransferType.Save;
+ using var saveCancellation = transferViewModel.GetCancellation();
+ await transferViewModel.PerformOperationAsync(async ct =>
+ {
+ await persistable.SaveAsync(ct);
+ }, saveCancellation.Token);
+ }
+ else
+ await persistable.SaveAsync(cancellationToken);
+
+ await PerformFileLoadAsync(cancellationToken);
}
- else
- await persistable.SaveAsync(cancellationToken);
-
- await PerformFileLoadAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // Cancellation, nothing to report
+ }
+ catch (Exception)
+ {
+ if (BrowserViewModel.TransferViewModel is { } transferViewModel)
+ await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized());
}
}
}
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs
index 9c965cdf4..ed0921ce4 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs
@@ -281,22 +281,34 @@ protected virtual async Task NewItemAsync(string? itemType, CancellationToken ca
var formattedName = CollisionHelpers.GetAvailableName(
FormattingHelpers.SanitizeItemName(newItemViewModel.ItemName, "New item"),
CurrentFolder.Items.Select(x => x.Inner.Name));
- switch (itemType)
+ try
{
- case "File":
+ switch (itemType)
{
- var file = await modifiableFolder.CreateFileAsync(formattedName, false, cancellationToken);
- CurrentFolder.Items.Insert(new FileViewModel(file, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter());
- break;
- }
+ case "File":
+ {
+ var file = await modifiableFolder.CreateFileAsync(formattedName, false, cancellationToken);
+ 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).WithInitAsync(), Layouts.GetSorter());
- break;
+ case "Folder":
+ {
+ var folder = await modifiableFolder.CreateFolderAsync(formattedName, false, cancellationToken);
+ CurrentFolder.Items.Insert(new FolderViewModel(folder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter());
+ break;
+ }
}
}
+ catch (OperationCanceledException)
+ {
+ // Cancellation, nothing to report
+ }
+ catch (Exception)
+ {
+ if (TransferViewModel is not null)
+ await TransferViewModel.ReportErrorAsync("OperationFailed".ToLocalized());
+ }
}
[RelayCommand]
@@ -324,77 +336,92 @@ protected virtual async Task ImportItemAsync(string? itemType, CancellationToken
itemType = storableTypeViewModel.SelectedOption ?? storableTypeViewModel.StorableType.ToString();
}
- switch (itemType)
+ try
{
- case "File":
+ switch (itemType)
{
- var file = await FileExplorerService.PickFileAsync(null, false, cancellationToken);
- if (file is null)
- return;
-
- TransferViewModel.TransferType = TransferType.Copy;
- using var cts = TransferViewModel.GetCancellation(cancellationToken);
- await TransferViewModel.TransferAsync([ file ], async (item, token) =>
+ case "File":
{
- // Get available name to avoid collision
- var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name));
+ var file = await FileExplorerService.PickFileAsync(null, false, cancellationToken);
+ if (file is null)
+ return;
- // Copy
- var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token);
+ TransferViewModel.TransferType = TransferType.Copy;
+ using var cts = TransferViewModel.GetCancellation(cancellationToken);
+ await TransferViewModel.TransferAsync([ file ], async (item, token) =>
+ {
+ // Get available name to avoid collision
+ var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name));
- // Add to destination
- CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter());
- }, cts.Token);
+ // Copy
+ var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token);
- break;
- }
+ // Add to destination
+ CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter());
+ }, cts.Token);
- case "Folder":
- {
- var folder = await FileExplorerService.PickFolderAsync(null, false, cancellationToken);
- if (folder is null)
- return;
+ break;
+ }
- TransferViewModel.TransferType = TransferType.Copy;
- using var cts = TransferViewModel.GetCancellation(cancellationToken);
- await TransferViewModel.TransferAsync([ folder ], async (item, reporter, token) =>
+ case "Folder":
{
- // Get available name to avoid collision
- var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name));
+ var folder = await FileExplorerService.PickFolderAsync(null, false, cancellationToken);
+ if (folder is null)
+ return;
- // Copy
- var copiedFolder = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, reporter, token);
+ TransferViewModel.TransferType = TransferType.Copy;
+ using var cts = TransferViewModel.GetCancellation(cancellationToken);
+ await TransferViewModel.TransferAsync([ folder ], async (item, reporter, token) =>
+ {
+ // Get available name to avoid collision
+ var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name));
- // Add to destination
- CurrentFolder.Items.Insert(new FolderViewModel(copiedFolder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter());
- }, cts.Token);
+ // Copy
+ var copiedFolder = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, reporter, token);
- break;
- }
+ // Add to destination
+ CurrentFolder.Items.Insert(new FolderViewModel(copiedFolder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter());
+ }, cts.Token);
- case var t when t == "Gallery".ToLocalized():
- {
- var galleryItems = (await FileExplorerService.PickGalleryItemsAsync(cancellationToken)).OfType().ToArray();
- if (galleryItems.Length == 0)
- return;
+ break;
+ }
- TransferViewModel.TransferType = TransferType.Copy;
- using var cts = TransferViewModel.GetCancellation(cancellationToken);
- await TransferViewModel.TransferAsync(galleryItems, async (item, token) =>
+ case var t when t == "Gallery".ToLocalized():
{
- // Get available name to avoid collision
- var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name));
-
- // Copy
- var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token);
-
- // Add to destination
- CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter());
- }, cts.Token);
-
- break;
+ var galleryItems = (await FileExplorerService.PickGalleryItemsAsync(cancellationToken)).OfType().ToArray();
+ if (galleryItems.Length == 0)
+ return;
+
+ TransferViewModel.TransferType = TransferType.Copy;
+ using var cts = TransferViewModel.GetCancellation(cancellationToken);
+ await TransferViewModel.TransferAsync(galleryItems, async (item, token) =>
+ {
+ // Get available name to avoid collision
+ var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name));
+
+ // Copy
+ var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token);
+
+ // Add to destination
+ CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter());
+ }, cts.Token);
+
+ break;
+ }
}
}
+ catch (OperationCanceledException)
+ {
+ // Cancellation, nothing to report
+ }
+ catch (Exception)
+ {
+ await TransferViewModel.ReportErrorAsync("OperationFailed".ToLocalized());
+ }
+ finally
+ {
+ await TransferViewModel.HideAsync();
+ }
}
///
From e097c40f5daf1a05e0273ecf1ea18737d1909443 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 18:49:16 +0200
Subject: [PATCH 48/55] Minor fixes
---
.../SecureFolderFS.Core.WinFsp.csproj | 2 +-
.../SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs | 3 ++-
.../SecureFolderFS.Uno/UserControls/LoginOptions.xaml | 2 +-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj b/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj
index f4ebea679..fb8bff9bf 100644
--- a/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj
+++ b/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs b/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs
index c0d15dfab..fb6f43738 100644
--- a/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs
@@ -35,7 +35,8 @@ public static class WebDavRedirectorHelpers
using var parametersKey = Registry.LocalMachine.OpenSubKey(WEBCLIENT_PARAMETERS_KEY);
return parametersKey?.GetValue(FILE_SIZE_LIMIT_VALUE) switch
{
- int value => unchecked((uint)value),
+ int value when value < 0 => null,
+ int value => value > 0 ? unchecked((uint)value) : null,
// The WebDAV redirector falls back to the default limit when the value is not present
null => DEFAULT_FILE_SIZE_LIMIT,
diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml
index f4e0f0f53..9a78efa51 100644
--- a/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml
@@ -20,7 +20,7 @@
Content="{l:ResourceString Rid=SaveCredentials}"
IsChecked="{x:Bind ShouldSaveCredentials, Mode=TwoWay}"
IsEnabled="False"
- Visibility="{x:Bind AreCredentialsSaved, Mode=OneWay, Converter={StaticResource BoolInvertConverter}}" />
+ Visibility="{x:Bind AreCredentialsSaved, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter='invert'}" />
public event EventHandler? VaultUnlocked;
+ ///
+ public event EventHandler? StateChanged;
+
public LoginViewModel(IFolder vaultFolder, LoginViewType loginViewMode, KeySequence? keySequence = null)
{
ServiceProvider = DI.Default;
@@ -97,10 +99,15 @@ public async Task InitAsync(CancellationToken cancellationToken = default)
_loginSequence = new(loginItems);
IsLoginSequence = _loginSequence.Count > 1;
- // Set up the first authentication method
- var result = ProceedAuthentication();
- if (!result.Successful)
- CurrentViewModel = new ErrorViewModel(result);
+ // Set up the first authentication method. At this point the sequence should advance to the
+ // first method; a Completed step means the vault has no usable authentication methods
+ CurrentViewModel = ProceedAuthentication(out var result) switch
+ {
+ AuthenticationStep.Faulted => new ErrorViewModel(result),
+ AuthenticationStep.Completed => new ErrorViewModel(new MessageResult(false,
+ "No authentication methods available.")),
+ _ => CurrentViewModel
+ };
}
catch (NotSupportedException)
{
@@ -156,8 +163,10 @@ private async Task RecoverAccessAsync(string? recoveryKey, CancellationToken can
}
catch (Exception ex)
{
- // TODO: Report to user
- _ = ex;
+ // Notify both the current authentication view and any listening host
+ var result = Result.Failure(ex);
+ CurrentViewModel?.Report(result);
+ StateChanged?.Invoke(this, new ErrorReportedEventArgs(result));
}
}
}
@@ -192,9 +201,12 @@ private void RestartLoginProcess()
// Dispose built key sequence
_keySequence.Dispose();
_loginSequence?.Reset();
- var result = ProceedAuthentication();
- if (!result.Successful)
- CurrentViewModel = new ErrorViewModel(result);
+ CurrentViewModel = ProceedAuthentication(out var result) switch
+ {
+ AuthenticationStep.Faulted => new ErrorViewModel(result),
+ AuthenticationStep.Completed => new ErrorViewModel(new MessageResult(false, "No authentication methods available.")),
+ _ => CurrentViewModel
+ };
}
private async Task TryUnlockAsync(CancellationToken cancellationToken = default)
@@ -228,22 +240,32 @@ private async Task TryUnlockAsync(CancellationToken cancellationToken = de
}
}
- private IResult ProceedAuthentication()
+ ///
+ /// Advances the authentication sequence to the next method.
+ ///
+ ///
+ /// When the step is , contains the failure; otherwise .
+ ///
+ /// The outcome of advancing the sequence.
+ private AuthenticationStep ProceedAuthentication(out IResult result)
{
+ result = Result.Success;
try
{
+ // MoveNext returning false is the legitimate end-of-sequence signal, not an error
if (_loginSequence is null || !_loginSequence.MoveNext())
- return new MessageResult(false, "No authentication methods available.");
+ return AuthenticationStep.Completed;
// Get the appropriate method
var viewModel = _loginSequence.Current;
CurrentViewModel = viewModel;
- return Result.Success;
+ return AuthenticationStep.Advanced;
}
catch (Exception ex)
{
- return Result.Failure(ex);
+ result = Result.Failure(ex);
+ return AuthenticationStep.Faulted;
}
}
@@ -288,15 +310,21 @@ private async void CurrentViewModel_CredentialsProvided(object? sender, Credenti
// Add authentication
_keySequence.Add(e.Authentication);
- var result = ProceedAuthentication();
- if (!result.Successful && CurrentViewModel is not ErrorViewModel)
+ var step = ProceedAuthentication(out var result);
+ switch (step)
{
- // Reached the end in which case we should try to unlock the vault
- if (!await TryUnlockAsync())
- {
- // If login failed, restart the process
- RestartLoginProcess();
- }
+ case AuthenticationStep.Faulted:
+ // A genuine error while advancing - report it
+ CurrentViewModel?.Report(result);
+ break;
+
+ case AuthenticationStep.Completed:
+ // Reached the end of the sequence - try to unlock the vault
+ if (!await TryUnlockAsync())
+ RestartLoginProcess();
+ break;
+
+ // AuthenticationStep.Advanced: the next method's view is now shown, nothing to do
}
}
finally
@@ -331,6 +359,7 @@ partial void OnCurrentViewModelChanged(ReportableViewModel? oldValue, Reportable
public void Dispose()
{
VaultUnlocked = null;
+ StateChanged = null;
_vaultWatcherModel.StateChanged -= VaultWatcherModel_StateChanged;
(CurrentViewModel as IDisposable)?.Dispose();
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs
index 81a529ad5..c900fcf13 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs
@@ -16,12 +16,13 @@
using SecureFolderFS.Shared.ComponentModel;
using SecureFolderFS.Shared.EventArguments;
using SecureFolderFS.Shared.Extensions;
+using SecureFolderFS.Shared.Models;
namespace SecureFolderFS.Sdk.ViewModels.Views.Vault
{
[Inject, Inject, Inject, Inject]
[Bindable(true)]
- public sealed partial class VaultLoginViewModel : BaseDesignationViewModel, IVaultViewContext, INavigatable, IAsyncInitialize, IDisposable
+ public sealed partial class VaultLoginViewModel : BaseDesignationViewModel, IVaultViewContext, INavigatable, IAsyncInitialize, IProgress, IDisposable
{
private CancellationTokenSource? _connectionCts;
@@ -29,6 +30,7 @@ public sealed partial class VaultLoginViewModel : BaseDesignationViewModel, IVau
[ObservableProperty] private bool _IsConnected;
[ObservableProperty] private bool _IsProgressing;
[ObservableProperty] private LoginViewModel? _LoginViewModel;
+ [ObservableProperty] private InfoBarViewModel _StatusInfoBar = new();
public INavigationService VaultNavigation { get; }
@@ -105,11 +107,12 @@ private async Task ConnectToVaultAsync(CancellationToken cancellationToken)
try
{
+ StatusInfoBar.IsOpen = false;
LoginViewModel?.Dispose();
var result = await VaultViewModel.VaultModel.TryConnectAsync(linkedCts.Token);
if (!result.TryGetValue(out var vaultFolder))
{
- // TODO: Report error
+ Report(MessageResult.WithMessage(result, "ConnectionFailed".ToLocalized()));
return;
}
@@ -124,6 +127,10 @@ private async Task ConnectToVaultAsync(CancellationToken cancellationToken)
LoginViewModel?.Dispose();
LoginViewModel = null;
}
+ catch (Exception ex)
+ {
+ Report(new MessageResult(ex, ex.Message));
+ }
finally
{
IsProgressing = false;
@@ -169,10 +176,29 @@ private async Task BeginRecoveryAsync(CancellationToken cancellationToken)
private async Task UnlockAsync(IDisposable unlockContract)
{
+ StatusInfoBar.IsOpen = false;
+
+ UnlockedVaultViewModel unlockedVaultViewModel;
+ try
+ {
+ unlockedVaultViewModel = await VaultViewModel.UnlockAsync(unlockContract, IsReadOnly);
+ }
+ catch (Exception ex)
+ {
+ // Mounting failed - the credentials were correct, so keep this view alive for a retry
+ unlockContract.Dispose();
+ Report(new MessageResult(ex, "UnlockFailed".ToLocalized()));
+
+ // Reset the login state so the user can attempt to unlock again
+ if (LoginViewModel is not null)
+ await LoginViewModel.InitAsync();
+
+ return;
+ }
+
try
{
// Navigate away
- var unlockedVaultViewModel = await VaultViewModel.UnlockAsync(unlockContract, IsReadOnly);
NavigationRequested?.Invoke(this, new UnlockNavigationRequestedEventArgs(unlockedVaultViewModel, this));
// Show vault tutorial
@@ -193,6 +219,27 @@ private async Task UnlockAsync(IDisposable unlockContract)
}
}
+ ///
+ public void Report(IResult result)
+ {
+ StatusInfoBar.Title = result.GetMessage("UnknownError".ToLocalized());
+ StatusInfoBar.Severity = Severity.Critical;
+ StatusInfoBar.IsCloseable = true;
+ StatusInfoBar.IsOpen = true;
+ }
+
+ private void LoginViewModel_StateChanged(object? sender, EventArgs e)
+ {
+ if (e is ErrorReportedEventArgs args)
+ Report(MessageResult.WithMessage(args.Result, "RecoveryFailed".ToLocalized()));
+ }
+
+ partial void OnLoginViewModelChanged(LoginViewModel? oldValue, LoginViewModel? newValue)
+ {
+ oldValue?.StateChanged -= LoginViewModel_StateChanged;
+ newValue?.StateChanged += LoginViewModel_StateChanged;
+ }
+
private async void LoginViewModel_VaultUnlocked(object? sender, VaultUnlockedEventArgs e)
{
await UnlockAsync(e.UnlockContract);
From 33f773286185d82eaac41b1ec51e0eb5855f0a03 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 19:34:47 +0200
Subject: [PATCH 50/55] Added better cancellation handling in
BrowserItemViewModel
---
.../Storage/Browser/BrowserItemViewModel.cs | 64 ++++++++++++++-----
1 file changed, 48 insertions(+), 16 deletions(-)
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs
index c56f3cf77..47190409c 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs
@@ -74,7 +74,19 @@ protected virtual async Task OpenInExternalAppAsync(CancellationToken cancellati
if (Inner is not IFile file)
return;
- await ShareService.OpenFileWithAsync(file);
+ try
+ {
+ await ShareService.OpenFileWithAsync(file);
+ }
+ catch (OperationCanceledException)
+ {
+ // Cancellation, nothing to report
+ }
+ catch (Exception)
+ {
+ if (BrowserViewModel.TransferViewModel is { } transferViewModel)
+ await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized());
+ }
}
[RelayCommand]
@@ -148,10 +160,13 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner)
}, BrowserViewModel.Layouts.GetSorter());
}, cts.Token);
}
- catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
+ catch (OperationCanceledException)
+ {
+ // Cancellation, nothing to report
+ }
+ catch (Exception)
{
- _ = ex;
- // TODO: Report error
+ await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized());
}
finally
{
@@ -214,10 +229,13 @@ await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, r
}, BrowserViewModel.Layouts.GetSorter());
}, cts.Token);
}
- catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
+ catch (OperationCanceledException)
{
- _ = ex;
- // TODO: Report error
+ // Cancellation, nothing to report
+ }
+ catch (Exception)
+ {
+ await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized());
}
finally
{
@@ -268,10 +286,14 @@ await OverlayService.ShowAsync(new MessageOverlayViewModel()
UpdateStorable(renamedStorable);
_ = InitAsync(cancellationToken);
}
- catch (Exception ex)
+ catch (OperationCanceledException)
{
- // TODO: Report error
- _ = ex;
+ // Cancellation, nothing to report
+ }
+ catch (Exception)
+ {
+ if (BrowserViewModel.TransferViewModel is { } transferViewModel)
+ await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized());
}
}
@@ -308,11 +330,15 @@ protected virtual async Task DeleteAsync(CancellationToken cancellationToken)
if (recycleBin is null)
return;
+ // Size calculation may take a while for large folders - show a cancellable indeterminate state
+ transferViewModel.ShowIndeterminate("Calculating".ToLocalized());
+
// Key the sizes by item ID - the transfer callback receives the storable,
// so positional lookups against the view model array would not match
var sizes = new Dictionary();
foreach (var item in items)
{
+ cts.Token.ThrowIfCancellationRequested();
sizes[item.Inner.Id] = item.Inner switch
{
IFile file => await file.GetSizeAsync(cts.Token) ?? 0L,
@@ -390,10 +416,13 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner)
}, cts.Token);
}
}
- catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
+ catch (OperationCanceledException)
{
- _ = ex;
- // TODO: Report error
+ // Cancellation, nothing to report
+ }
+ catch (Exception)
+ {
+ await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized());
}
finally
{
@@ -436,10 +465,13 @@ await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, r
}
}, cts.Token);
}
- catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
+ catch (OperationCanceledException)
+ {
+ // Cancellation, nothing to report
+ }
+ catch (Exception)
{
- _ = ex;
- // TODO: Report error
+ await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized());
}
finally
{
From b7d4a94bee985a49d9545705cdbdc24e164b405e Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 20:06:07 +0200
Subject: [PATCH 51/55] Added status InfoBar for login page on Uno
---
.../VaultPreviewRootControl.xaml | 33 +++++
.../Views/Vault/VaultLoginPage.xaml | 118 +++++++++++-------
.../ViewModels/Controls/LoginViewModel.cs | 12 +-
.../Views/Root/VaultPreviewViewModel.cs | 69 ++++++++--
4 files changed, 168 insertions(+), 64 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml
index 618b2bd9a..54fb1a047 100644
--- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml
@@ -2,6 +2,7 @@
x:Class="SecureFolderFS.Uno.UserControls.InterfaceRoot.VaultPreviewRootControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:anim="using:CommunityToolkit.WinUI.Animations"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:l="using:SecureFolderFS.Uno.Localization"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -94,6 +95,38 @@
LockVaultCommand="{x:Bind ViewModel.LockVaultCommand, Mode=OneWay}"
RevealFolderCommand="{x:Bind ViewModel.RevealFolderCommand, Mode=OneWay}" />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultLoginPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultLoginPage.xaml
index 1b3b976fe..fcdef9a49 100644
--- a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultLoginPage.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultLoginPage.xaml
@@ -2,62 +2,86 @@
x:Class="SecureFolderFS.Uno.Views.Vault.VaultLoginPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:anim="using:CommunityToolkit.WinUI.Animations"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:uc="using:SecureFolderFS.Uno.UserControls"
NavigationCacheMode="Disabled"
mc:Ignorable="d">
-
-
-
+
+
+
-
-
-
-
+
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
-
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs
index ab4aa0a5a..1478d5349 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs
@@ -336,16 +336,12 @@ private async void CurrentViewModel_CredentialsProvided(object? sender, Credenti
partial void OnCurrentViewModelChanged(ReportableViewModel? oldValue, ReportableViewModel? newValue)
{
// Detach old
- if (oldValue is not null)
- oldValue.StateChanged -= CurrentViewModel_StateChanged;
-
+ oldValue?.StateChanged -= CurrentViewModel_StateChanged;
if (oldValue is AuthenticationViewModel oldViewModel)
oldViewModel.CredentialsProvided -= CurrentViewModel_CredentialsProvided;
// Attach new
- if (newValue is not null)
- newValue.StateChanged += CurrentViewModel_StateChanged;
-
+ newValue?.StateChanged += CurrentViewModel_StateChanged;
if (newValue is AuthenticationViewModel newViewModel)
{
newViewModel.CredentialsProvided += CurrentViewModel_CredentialsProvided;
@@ -363,9 +359,7 @@ public void Dispose()
_vaultWatcherModel.StateChanged -= VaultWatcherModel_StateChanged;
(CurrentViewModel as IDisposable)?.Dispose();
- if (CurrentViewModel is not null)
- CurrentViewModel.StateChanged -= CurrentViewModel_StateChanged;
-
+ CurrentViewModel?.StateChanged -= CurrentViewModel_StateChanged;
if (CurrentViewModel is AuthenticationViewModel authenticationViewModel)
authenticationViewModel.CredentialsProvided -= CurrentViewModel_CredentialsProvided;
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/VaultPreviewViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/VaultPreviewViewModel.cs
index 426d67665..73d5f7240 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/VaultPreviewViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/VaultPreviewViewModel.cs
@@ -1,4 +1,8 @@
-using CommunityToolkit.Mvvm.ComponentModel;
+using System;
+using System.ComponentModel;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using SecureFolderFS.Sdk.Attributes;
@@ -14,16 +18,13 @@
using SecureFolderFS.Shared;
using SecureFolderFS.Shared.ComponentModel;
using SecureFolderFS.Shared.Extensions;
-using System;
-using System.ComponentModel;
-using System.Threading;
-using System.Threading.Tasks;
+using SecureFolderFS.Shared.Models;
namespace SecureFolderFS.Sdk.ViewModels.Views.Root
{
[Inject, Inject, Inject]
[Bindable(true)]
- public sealed partial class VaultPreviewViewModel : ObservableObject, IAsyncInitialize, IRecipient, IRecipient, IDisposable
+ public sealed partial class VaultPreviewViewModel : ObservableObject, IAsyncInitialize, IRecipient, IRecipient, IProgress, IDisposable
{
private readonly INavigationService _vaultNavigation;
@@ -31,6 +32,7 @@ public sealed partial class VaultPreviewViewModel : ObservableObject, IAsyncInit
[ObservableProperty] private LoginViewModel? _LoginViewModel;
[ObservableProperty] private VaultViewModel _VaultViewModel;
[ObservableProperty] private bool _IsReadOnly;
+ [ObservableProperty] private InfoBarViewModel _StatusInfoBar = new();
public VaultPreviewViewModel(VaultViewModel vaultViewModel, INavigationService vaultNavigation)
{
@@ -113,8 +115,20 @@ private async Task LockVaultAsync()
if (UnlockedVaultViewModel is null)
return;
- // Lock vault
- await UnlockedVaultViewModel.DisposeAsync();
+ try
+ {
+ // Lock vault
+ await UnlockedVaultViewModel.DisposeAsync();
+ }
+ catch (Exception ex)
+ {
+ // Unmounting failed (e.g. files are still open) - the vault remains unlocked,
+ // so don't navigate away or announce a lock that didn't happen
+ Report(new MessageResult(ex, "LockFailed".ToLocalized()));
+ return;
+ }
+
+ StatusInfoBar.IsOpen = false;
// Prepare login page
var loginPageViewModel = new VaultLoginViewModel(UnlockedVaultViewModel.VaultViewModel, _vaultNavigation);
@@ -127,11 +141,28 @@ private async Task LockVaultAsync()
private async Task UnlockAsync(IDisposable unlockContract)
{
+ StatusInfoBar.IsOpen = false;
+
try
{
// Unlock the vault
UnlockedVaultViewModel = await VaultViewModel.UnlockAsync(unlockContract, IsReadOnly);
+ }
+ catch (Exception ex)
+ {
+ // Mounting failed - the credentials were correct, so keep this view alive for a retry
+ unlockContract.Dispose();
+ Report(new MessageResult(ex, "UnlockFailed".ToLocalized()));
+ // Reset the login state so the user can attempt to unlock again
+ if (LoginViewModel is not null)
+ await LoginViewModel.InitAsync();
+
+ return;
+ }
+
+ try
+ {
// Setup dashboard
var dashboardNavigation = DI.Service();
var dashboardViewModel = new VaultDashboardViewModel(UnlockedVaultViewModel, _vaultNavigation, dashboardNavigation);
@@ -161,11 +192,32 @@ await _vaultNavigation.ForgetNavigateSpecificViewAsync(
}
}
+ ///
+ public void Report(IResult result)
+ {
+ StatusInfoBar.Title = result.GetMessage("UnknownError".ToLocalized());
+ StatusInfoBar.Severity = Severity.Critical;
+ StatusInfoBar.IsCloseable = true;
+ StatusInfoBar.IsOpen = true;
+ }
+
+ private void LoginViewModel_StateChanged(object? sender, EventArgs e)
+ {
+ if (e is ErrorReportedEventArgs args)
+ Report(MessageResult.WithMessage(args.Result, "RecoveryFailed".ToLocalized()));
+ }
+
private async void LoginViewModel_VaultUnlocked(object? sender, VaultUnlockedEventArgs e)
{
await UnlockAsync(e.UnlockContract);
}
+ partial void OnLoginViewModelChanged(LoginViewModel? oldValue, LoginViewModel? newValue)
+ {
+ oldValue?.StateChanged -= LoginViewModel_StateChanged;
+ newValue?.StateChanged += LoginViewModel_StateChanged;
+ }
+
///
public void Dispose()
{
@@ -173,6 +225,7 @@ public void Dispose()
if (LoginViewModel is not null)
{
LoginViewModel.VaultUnlocked -= LoginViewModel_VaultUnlocked;
+ LoginViewModel.StateChanged -= LoginViewModel_StateChanged;
LoginViewModel.Dispose();
}
}
From 5f8c90901ba3aebb26cb76c3c1cddb534c84db31 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 21:32:03 +0200
Subject: [PATCH 52/55] Report login errors on MAUI
---
.../Views/Vault/LoginPage.xaml.cs | 25 ++++++++++++++++---
.../Views/Vault/VaultLoginViewModel.cs | 3 ++-
2 files changed, 24 insertions(+), 4 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml.cs b/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml.cs
index 1bf54723f..4e5e64d8e 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel;
using SecureFolderFS.Maui.Extensions;
using SecureFolderFS.Maui.ServiceImplementation;
using SecureFolderFS.Sdk.AppModels;
@@ -6,6 +7,7 @@
using SecureFolderFS.Sdk.Extensions;
using SecureFolderFS.Sdk.Helpers;
using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Sdk.ViewModels.Controls;
using SecureFolderFS.Sdk.ViewModels.Views.Vault;
using SecureFolderFS.Shared;
using SecureFolderFS.Shared.EventArguments;
@@ -36,6 +38,8 @@ public void ApplyQueryAttributes(IDictionary query)
{
ViewModel.NavigationRequested -= ViewModel_NavigationRequested;
ViewModel.NavigationRequested += ViewModel_NavigationRequested;
+ ViewModel.StatusInfoBar.PropertyChanged -= StatusInfoBar_PropertyChanged;
+ ViewModel.StatusInfoBar.PropertyChanged += StatusInfoBar_PropertyChanged;
if (ViewModel.VaultViewModel.VaultModel.IsRemote)
{
@@ -54,11 +58,26 @@ public void ApplyQueryAttributes(IDictionary query)
OnPropertyChanged(nameof(ViewModel));
}
- protected override void OnNavigatingFrom(NavigatingFromEventArgs args)
+ private async void StatusInfoBar_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
- if (ViewModel is not null)
- ViewModel.NavigationRequested -= ViewModel_NavigationRequested;
+ if (sender is not InfoBarViewModel infoBarViewModel)
+ return;
+
+ // Report login errors when it is expected
+ if (infoBarViewModel.IsOpen)
+ {
+ infoBarViewModel.IsOpen = false;
+ await DisplayAlertAsync(
+ infoBarViewModel.Title,
+ infoBarViewModel.Message,
+ "Close".ToLocalized());
+ }
+ }
+ protected override void OnNavigatingFrom(NavigatingFromEventArgs args)
+ {
+ ViewModel?.NavigationRequested -= ViewModel_NavigationRequested;
+ ViewModel?.StatusInfoBar.PropertyChanged -= StatusInfoBar_PropertyChanged;
base.OnNavigatingFrom(args);
}
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs
index c900fcf13..46a547b5a 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs
@@ -222,7 +222,8 @@ private async Task UnlockAsync(IDisposable unlockContract)
///
public void Report(IResult result)
{
- StatusInfoBar.Title = result.GetMessage("UnknownError".ToLocalized());
+ StatusInfoBar.Title = "ErrorOccurred".ToLocalized();
+ StatusInfoBar.Message = result.GetMessage("UnknownError".ToLocalized());
StatusInfoBar.Severity = Severity.Critical;
StatusInfoBar.IsCloseable = true;
StatusInfoBar.IsOpen = true;
From 75e56fe376f926c549755ac02c2b665ddb136a84 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 23:11:20 +0200
Subject: [PATCH 53/55] Better reporting for Credentials dialog
---
.../Popups/CredentialsPopup.xaml | 24 ++++++++++
.../Popups/CredentialsPopup.xaml.cs | 22 ++++-----
.../Dialogs/CredentialsDialog.xaml | 47 ++++++++++++-------
.../Dialogs/CredentialsDialog.xaml.cs | 24 +++-------
.../CredentialsConfirmationViewModel.cs | 26 +++++++---
.../Credentials/CredentialsResetViewModel.cs | 40 ++++++++++------
.../Overlays/CredentialsOverlayViewModel.cs | 27 ++++++++++-
7 files changed, 138 insertions(+), 72 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml
index 4471df128..a839d1369 100644
--- a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml
+++ b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml
@@ -119,6 +119,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml.cs b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml.cs
index c818aafd1..14e8355c9 100644
--- a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml.cs
@@ -110,13 +110,10 @@ private async void ResetViewButton_Click(object? sender, EventArgs e)
button.IsEnabled = false;
await Task.Delay(100);
- await credentialsResetViewModel.ConfirmAsync(default);
- await HideAsync();
- }
- catch (Exception ex)
- {
- // TODO: Report to user
- _ = ex;
+ var result = await credentialsResetViewModel.ConfirmAsync(default);
+ ViewModel?.Report(result);
+ if (result.Successful)
+ await HideAsync();
}
finally
{
@@ -137,13 +134,10 @@ private async void ConfirmationViewButton_Click(object? sender, EventArgs e)
button.IsEnabled = false;
await Task.Delay(100);
- await credentialsConfirmation.ConfirmAsync(default);
- await HideAsync();
- }
- catch (Exception ex)
- {
- // TODO: Report to user
- _ = ex;
+ var result = await credentialsConfirmation.ConfirmAsync();
+ ViewModel?.Report(result);
+ if (result.Successful)
+ await HideAsync();
}
finally
{
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml b/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml
index 3e97d670e..0bc97aff4 100644
--- a/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml
@@ -219,22 +219,33 @@
Click="GoBack_Click" />
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml.cs b/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml.cs
index c7d4a54a4..41e9c16e1 100644
--- a/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml.cs
@@ -66,32 +66,20 @@ private async void ContentDialog_PrimaryButtonClick(ContentDialog sender, Conten
case CredentialsResetViewModel credentialsReset:
{
- try
- {
- await credentialsReset.ConfirmAsync(default);
+ var result = await credentialsReset.ConfirmAsync(default);
+ ViewModel.Report(result);
+ if (result.Successful)
await HideAsync();
- }
- catch (Exception ex)
- {
- // TODO: Report to user
- _ = ex;
- }
break;
}
case CredentialsConfirmationViewModel credentialsConfirmation:
{
- try
- {
- await credentialsConfirmation.ConfirmAsync(default);
+ var result = await credentialsConfirmation.ConfirmAsync();
+ ViewModel.Report(result);
+ if (result.Successful)
await HideAsync();
- }
- catch (Exception ex)
- {
- // TODO: Report to user
- _ = ex;
- }
break;
}
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs
index 97a846501..adce0729a 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs
@@ -4,7 +4,6 @@
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
using OwlCore.Storage;
using SecureFolderFS.Sdk.AppModels;
using SecureFolderFS.Sdk.Attributes;
@@ -47,13 +46,26 @@ public CredentialsConfirmationViewModel(IFolder vaultFolder, RegisterViewModel r
RegisterViewModel.CredentialsProvided += RegisterViewModel_CredentialsProvided;
}
- [RelayCommand]
- public async Task ConfirmAsync(CancellationToken cancellationToken)
+ ///
+ /// Confirms the credentials by performing either a removal or modification operation, depending on the current context.
+ ///
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation. Value is a result of type indicating the success or failure of the operation.
+ public async Task ConfirmAsync(CancellationToken cancellationToken = default)
{
- if (IsRemoving)
- await RemoveAsync(cancellationToken);
- else
- await ModifyAsync(cancellationToken);
+ try
+ {
+ if (IsRemoving)
+ await RemoveAsync(cancellationToken);
+ else
+ await ModifyAsync(cancellationToken);
+
+ return Result.Success;
+ }
+ catch (Exception ex)
+ {
+ return Result.Failure(ex);
+ }
}
private async Task ModifyAsync(CancellationToken cancellationToken)
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs
index b01cff97f..50ebfd34b 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs
@@ -5,7 +5,6 @@
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
using OwlCore.Storage;
using SecureFolderFS.Sdk.AppModels;
using SecureFolderFS.Sdk.Attributes;
@@ -55,22 +54,35 @@ public async Task InitAsync(CancellationToken cancellationToken = default)
RegisterViewModel.CurrentViewModel = AuthenticationOptions.FirstOrDefault();
}
- [RelayCommand]
- public async Task ConfirmAsync(CancellationToken cancellationToken)
+ ///
+ /// Confirms the credentials reset process and applies the updated authentication configuration for the vault.
+ ///
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation. Value is an indicating the success or failure of the confirmation process.
+ public async Task ConfirmAsync(CancellationToken cancellationToken)
{
- ArgumentNullException.ThrowIfNull(RegisterViewModel.CurrentViewModel);
- RegisterViewModel.ConfirmCredentialsCommand.Execute(null);
-
- var key = await _credentialsTcs.Task;
- var configuredOptions = await VaultService.GetVaultOptionsAsync(_vaultFolder, cancellationToken);
- var newOptions = configuredOptions with
+ try
{
- UnlockProcedure = new AuthenticationMethod([ RegisterViewModel.CurrentViewModel.Id ], null)
- };
+ ArgumentNullException.ThrowIfNull(RegisterViewModel.CurrentViewModel);
+ RegisterViewModel.ConfirmCredentialsCommand.Execute(null);
+
+ var key = await _credentialsTcs.Task;
+ var configuredOptions = await VaultService.GetVaultOptionsAsync(_vaultFolder, cancellationToken);
+ var newOptions = configuredOptions with
+ {
+ UnlockProcedure = new AuthenticationMethod([ RegisterViewModel.CurrentViewModel.Id ], null)
+ };
- await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, _unlockContract, key, newOptions, cancellationToken);
- if (!string.IsNullOrEmpty(configuredOptions.VaultId))
- PersistedCredentialsModel.Instance.Remove(configuredOptions.VaultId);
+ await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, _unlockContract, key, newOptions, cancellationToken);
+ if (!string.IsNullOrEmpty(configuredOptions.VaultId))
+ PersistedCredentialsModel.Instance.Remove(configuredOptions.VaultId);
+
+ return Result.Success;
+ }
+ catch (Exception ex)
+ {
+ return Result.Failure(ex);
+ }
}
private void RegisterViewModel_CredentialsProvided(object? sender, CredentialsProvidedEventArgs e)
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs
index 5e5bed8d4..a13c17a6d 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs
@@ -21,7 +21,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays
{
[Bindable(true)]
[Inject, Inject]
- public sealed partial class CredentialsOverlayViewModel : OverlayViewModel, IAsyncInitialize, IDisposable
+ public sealed partial class CredentialsOverlayViewModel : OverlayViewModel, IAsyncInitialize, IProgress, IDisposable
{
private readonly IFolder _vaultFolder;
private readonly KeySequence _keySequence;
@@ -31,6 +31,7 @@ public sealed partial class CredentialsOverlayViewModel : OverlayViewModel, IAsy
[ObservableProperty] private RegisterViewModel _RegisterViewModel;
[ObservableProperty] private CredentialsSelectionViewModel _SelectionViewModel;
[ObservableProperty] private INotifyPropertyChanged? _SelectedViewModel;
+ [ObservableProperty] private InfoBarViewModel _StatusInfoBar = new();
public CredentialsOverlayViewModel(IFolder vaultFolder, string? vaultName, AuthenticationStage authenticationStage)
{
@@ -47,10 +48,33 @@ public CredentialsOverlayViewModel(IFolder vaultFolder, string? vaultName, Authe
PrimaryText = "Continue".ToLocalized();
LoginViewModel.VaultUnlocked += LoginViewModel_VaultUnlocked;
+ LoginViewModel.StateChanged += LoginViewModel_StateChanged;
RegisterViewModel.PropertyChanged += RegisterViewModel_PropertyChanged;
SelectionViewModel.ConfirmationRequested += SelectionViewModel_ConfirmationRequested;
}
+ ///
+ public void Report(IResult result)
+ {
+ if (result.Successful)
+ {
+ StatusInfoBar.IsOpen = false;
+ return;
+ }
+
+ StatusInfoBar.Title = "CredentialsChangeFailed".ToLocalized();
+ StatusInfoBar.Message = result.GetMessage("UnknownError".ToLocalized());
+ StatusInfoBar.Severity = Severity.Critical;
+ StatusInfoBar.IsCloseable = true;
+ StatusInfoBar.IsOpen = true;
+ }
+
+ private void LoginViewModel_StateChanged(object? sender, EventArgs e)
+ {
+ if (e is ErrorReportedEventArgs args)
+ Report(args.Result);
+ }
+
///
public async Task InitAsync(CancellationToken cancellationToken = default)
{
@@ -118,6 +142,7 @@ public void Dispose()
RegisterViewModel.PropertyChanged -= RegisterViewModel_PropertyChanged;
SelectionViewModel.ConfirmationRequested -= SelectionViewModel_ConfirmationRequested;
LoginViewModel.VaultUnlocked -= LoginViewModel_VaultUnlocked;
+ LoginViewModel.StateChanged -= LoginViewModel_StateChanged;
(SelectedViewModel as IDisposable)?.Dispose();
SelectionViewModel.Dispose();
LoginViewModel.Dispose();
From 538ae56e95a468439a2987ce1ebf056fd9744205 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 4 Jul 2026 23:23:11 +0200
Subject: [PATCH 54/55] Better error reporting for recycle bin
---
.../Modals/Vault/RecycleBinModalPage.xaml | 25 +++++++++++++++-
.../Dialogs/RecycleBinDialog.xaml | 14 ++++++++-
.../Overlays/RecycleBinOverlayViewModel.cs | 29 +++++++++++++++++--
.../Views/Vault/VaultLoginViewModel.cs | 2 +-
4 files changed, 65 insertions(+), 5 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/RecycleBinModalPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/RecycleBinModalPage.xaml
index 7f375346b..cb5410b69 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/RecycleBinModalPage.xaml
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/RecycleBinModalPage.xaml
@@ -163,6 +163,7 @@
+
@@ -217,9 +218,31 @@
-
+
+
+
+
+
+
+
+
+
+
-
+
+
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs
index 13ec8be04..ee6ed12ee 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs
@@ -11,6 +11,7 @@
using CommunityToolkit.Mvvm.Input;
using OwlCore.Storage;
using SecureFolderFS.Sdk.Attributes;
+using SecureFolderFS.Sdk.Enums;
using SecureFolderFS.Sdk.Extensions;
using SecureFolderFS.Sdk.Helpers;
using SecureFolderFS.Sdk.Services;
@@ -20,6 +21,7 @@
using SecureFolderFS.Shared;
using SecureFolderFS.Shared.ComponentModel;
using SecureFolderFS.Shared.Extensions;
+using SecureFolderFS.Shared.Models;
using SecureFolderFS.Shared.Helpers;
using SecureFolderFS.Storage.Extensions;
using SecureFolderFS.Storage.VirtualFileSystem;
@@ -28,7 +30,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays
{
[Bindable(true)]
[Inject, Inject]
- public sealed partial class RecycleBinOverlayViewModel : BaseDesignationViewModel, IAsyncInitialize, IDisposable
+ public sealed partial class RecycleBinOverlayViewModel : BaseDesignationViewModel, IAsyncInitialize, IProgress, IDisposable
{
private readonly SynchronizationContext? _synchronizationContext;
private long _occupiedSize;
@@ -43,6 +45,7 @@ public sealed partial class RecycleBinOverlayViewModel : BaseDesignationViewMode
[ObservableProperty] private UnlockedVaultViewModel _UnlockedVaultViewModel;
[ObservableProperty] private ObservableCollection _Items;
[ObservableProperty] private ObservableCollection _SizeOptions;
+ [ObservableProperty] private InfoBarViewModel _StatusInfoBar = new();
public INavigator OuterNavigator { get; }
@@ -205,9 +208,12 @@ private async Task RestoreSelectedAsync(IList