diff --git a/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs b/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs index a8442fd2..3fc0599c 100644 --- a/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs +++ b/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs @@ -292,6 +292,12 @@ public interface ISettingsService #endregion + /// + /// Pre-loads secure settings from SecureStorage into memory cache. + /// Call this at app startup to avoid blocking on first access. + /// + Task PreloadSecureSettingsAsync(); + /// /// Clears all settings (for logout/reset). /// diff --git a/src/WayfarerMobile/App.xaml.cs b/src/WayfarerMobile/App.xaml.cs index 0e993630..39a480e9 100644 --- a/src/WayfarerMobile/App.xaml.cs +++ b/src/WayfarerMobile/App.xaml.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.Logging; using WayfarerMobile.Core.Interfaces; -using WayfarerMobile.Core.Models; -using WayfarerMobile.Data.Repositories; using WayfarerMobile.Helpers; using WayfarerMobile.Interfaces; using WayfarerMobile.Services; @@ -171,53 +169,20 @@ private void InitializeExceptionHandler() /// /// Starts background services for the application. + /// Sync pipeline (settings, drain, timeline, delegates) is handled by + /// which is idempotent + /// and also callable from the headless LocationTrackingService after process kill. /// private void StartBackgroundServices() { try { - // Pre-load secure settings to avoid blocking SecureStorage calls later - // This must happen early to prevent deadlocks on Android when API calls access ServerUrl/ApiToken - var settings = _serviceProvider.GetService() as SettingsService; - if (settings != null) - { - _ = settings.PreloadSecureSettingsAsync(); - _logger.LogDebug("Secure settings preload started"); - } - - // Start queue drain service (offline queue sync via check-in endpoint) - var queueDrainService = _serviceProvider.GetService(); - SafeFireAndForget(queueDrainService?.StartAsync(), "QueueDrainService"); - _logger.LogDebug("Queue drain service started"); - - // Start timeline sync service (offline mutation sync) - var timelineSyncService = _serviceProvider.GetService(); - SafeFireAndForget(timelineSyncService?.StartAsync(), "TimelineSyncService"); - _logger.LogDebug("Timeline sync service started"); - - // Wire up drain loop starter for background location services - // Both services piggyback on location wakeups for background sync - if (queueDrainService != null) - { - Action drainLoopStarter = () => - { - queueDrainService.StartDrainLoop(); - timelineSyncService?.StartDrainLoop(); // Piggyback on location wakeups - }; - Func isRunningChecker = () => queueDrainService.IsDrainLoopRunning; - -#if ANDROID - WayfarerMobile.Platforms.Android.Services.LocationTrackingService.SetDrainLoopStarter(drainLoopStarter, isRunningChecker); -#elif IOS - WayfarerMobile.Platforms.iOS.Services.LocationTrackingService.SetDrainLoopStarter(drainLoopStarter, isRunningChecker); -#endif - _logger.LogDebug("Drain loop starter wired to location services (includes timeline sync)"); - } - - // Initialize local timeline storage service (subscribes to location events) - var timelineStorageService = _serviceProvider.GetService(); - SafeFireAndForget(timelineStorageService?.InitializeAsync(), "LocalTimelineStorageService"); - _logger.LogDebug("Local timeline storage service initialization started"); + // Bootstrap the entire sync pipeline (settings preload, drain services, + // timeline storage, location delegates) via shared idempotent bootstrapper. + // This same method is called from LocationTrackingService on headless restart. + SafeFireAndForget( + LocationPipelineWiring.EnsureBootstrappedAsync(_serviceProvider), + "LocationPipelineWiring"); // Note: Settings sync is handled by SettingsSyncService, triggered opportunistically // in AppLifecycleService.OnResumingAsync() with a 6-hour minimum interval @@ -231,10 +196,6 @@ private void StartBackgroundServices() SafeFireAndForget(visitNotificationService?.StartAsync(), "VisitNotificationService"); _logger.LogDebug("Visit notification service initialization started"); - // Wire up location tracking delegates for online/offline path decision - WireLocationTrackingDelegates(timelineStorageService); - _logger.LogDebug("Location tracking delegates wired"); - // Note: Location tracking service start is handled by OnWindowActivatedForServiceStart // to ensure deterministic timing after UI is fully initialized. } @@ -244,117 +205,6 @@ private void StartBackgroundServices() } } - /// - /// Wires up the location tracking delegates for online/offline path decision. - /// Called early during startup to ensure delegates are set before location service starts. - /// - /// The local timeline storage service for updating timeline. - private void WireLocationTrackingDelegates(LocalTimelineStorageService? timelineStorageService) - { - var apiClient = _serviceProvider.GetService(); - var locationQueue = _serviceProvider.GetService(); - - // Online submit delegate: Submit directly to server via log-location endpoint - // Return semantics: - // - Returns serverId: Server accepted the location - // - Returns null: Server explicitly skipped (threshold not met) - don't queue - // - Throws exception: Network or API failure - triggers offline queue fallback - Func> onlineSubmit = async location => - { - if (apiClient == null) - throw new InvalidOperationException("API client not available"); - - // Early connectivity check - avoids timeout delays when completely offline - // Only block on NetworkAccess.None (no network interface at all). - // Allow Local/ConstrainedInternet to attempt the call for LAN-only server deployments. - // The IsTransient check below handles actual network failures. - if (Connectivity.Current.NetworkAccess == NetworkAccess.None) - throw new HttpRequestException("No network connectivity"); - - // Convert to API request model with metadata - var request = new LocationLogRequest - { - Latitude = location.Latitude, - Longitude = location.Longitude, - Accuracy = location.Accuracy, - Altitude = location.Altitude, - Speed = location.Speed, - Timestamp = location.Timestamp, - Provider = location.Provider, - Bearing = location.Bearing, - // Metadata fields - captured at submission time - Source = "mobile-log", - IsUserInvoked = false, - AppVersion = DeviceMetadataHelper.GetAppVersion(), - AppBuild = DeviceMetadataHelper.GetAppBuild(), - DeviceModel = DeviceMetadataHelper.GetDeviceModel(), - OsVersion = DeviceMetadataHelper.GetOsVersion(), - BatteryLevel = DeviceMetadataHelper.GetBatteryLevel(), - IsCharging = DeviceMetadataHelper.GetIsCharging() - }; - - // Call the log-location endpoint (server is authoritative) - var result = await apiClient.LogLocationAsync(request, idempotencyKey: null); - - // Case 1: Server accepted or skipped - don't queue - // Note: log-location API may return just { "success": true } without locationId - if (result.Success) - { - // Only update local timeline if server returned a locationId (not skipped) - if (!result.Skipped && result.LocationId.HasValue && timelineStorageService != null) - { - await timelineStorageService.AddAcceptedLocationAsync(location, result.LocationId.Value); - } - // Return locationId if available, null otherwise (either skipped or accepted without ID) - return result.LocationId; - } - - // Case 2: Transient failure - throw to trigger offline queue - // Check both IsTransient flag AND status code, since ApiClient may not set - // IsTransient for HTTP status failures (they come through with IsTransient=false) - // Transient codes: 408 (Request Timeout), 429 (Too Many Requests), 5xx (Server Error) - // QueueDrainService handles these appropriately with retry logic. - var isTransientStatusCode = result.StatusCode.HasValue && - (result.StatusCode == 408 || result.StatusCode == 429 || result.StatusCode >= 500); - - if (result.IsTransient || isTransientStatusCode) - throw new HttpRequestException($"Transient failure: {result.Message}"); - - // Case 3: Permanent API failure (4xx client errors) - return null, don't queue - // These won't succeed on retry (bad request, unauthorized, etc.) and queueing them - // creates pending timeline entries that never clear since QueueDrainService's 4xx - // handling doesn't emit LocationSkipped events. - return null; - }; - - // Offline queue delegate: Queue for background sync - Func> offlineQueue = async location => - { - if (locationQueue == null) - throw new InvalidOperationException("Location queue not available"); - - // Queue with isUserInvoked=false (background location) - var queuedId = await locationQueue.QueueLocationAsync(location, isUserInvoked: false); - - // Add pending entry to local timeline (will be updated when queue drains) - if (timelineStorageService != null) - { - await timelineStorageService.AddPendingLocationAsync(location, queuedId); - } - - return queuedId; - }; - - // Wire up delegates to platform-specific location services -#if ANDROID - WayfarerMobile.Platforms.Android.Services.LocationTrackingService.OnlineSubmitDelegate = onlineSubmit; - WayfarerMobile.Platforms.Android.Services.LocationTrackingService.OfflineQueueDelegate = offlineQueue; -#elif IOS - WayfarerMobile.Platforms.iOS.Services.LocationTrackingService.OnlineSubmitDelegate = onlineSubmit; - WayfarerMobile.Platforms.iOS.Services.LocationTrackingService.OfflineQueueDelegate = offlineQueue; -#endif - } - /// /// Starts the location tracking service if permissions are granted. /// The service runs 24/7 with context switching between high/normal performance modes. diff --git a/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs b/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs index 34a7e224..ec32fbf5 100644 --- a/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs +++ b/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs @@ -1337,13 +1337,18 @@ private async void LogLocationToQueue(LocationData location) return; } - // FALLBACK: Direct database queue (no delegates wired) + // FALLBACK: Direct database queue (no delegates wired — headless restart) + // Trigger sync pipeline bootstrap so future locations use wired delegates. + // Fire-and-forget: the current location still goes through the bare DB fallback. + _ = Task.Run(async () => await LocationPipelineWiring.EnsureBootstrappedAsync( + IPlatformApplication.Current?.Services)); + if (_databaseService != null) { // Get queue limit from settings (Preferences accessed directly since platform service can't use DI) var maxQueuedLocations = Preferences.Get(SettingsService.QueueLimitMaxLocationsKey, 25000); await _databaseService.QueueLocationAsync(location, maxQueuedLocations); - Log.Debug(LogTag, $"Queued via DatabaseService: {location}"); + Log.Debug(LogTag, $"Queued via DatabaseService (headless fallback): {location}"); // Notify that location was queued - used by LocalTimelineStorageService // to store with correct coordinates (may differ from broadcast when using best-wake-sample) diff --git a/src/WayfarerMobile/Services/LocalTimelineStorageService.cs b/src/WayfarerMobile/Services/LocalTimelineStorageService.cs index 5b6413f3..eac1e455 100644 --- a/src/WayfarerMobile/Services/LocalTimelineStorageService.cs +++ b/src/WayfarerMobile/Services/LocalTimelineStorageService.cs @@ -32,6 +32,7 @@ public class LocalTimelineStorageService : IDisposable private readonly ILogger _logger; private bool _isInitialized; private bool _disposed; + private int _eventsSubscribed; // Interlocked guard for SubscribeToEvents idempotency /// /// Time window for backfill/reconciliation operations (days). @@ -340,6 +341,14 @@ private async Task BackfillMissedQueueEntriesAsync() /// private void SubscribeToEvents() { + // Atomic guard: prevent duplicate event subscriptions across concurrent/retry calls. + // Duplicate subscriptions would produce duplicate timeline entries per location event. + if (Interlocked.CompareExchange(ref _eventsSubscribed, 1, 0) != 0) + { + _logger.LogDebug("Event subscriptions already active, skipping"); + return; + } + // Subscribe to LocationQueued (not LocationReceived) to ensure we store // the same coordinates that will be synced. On Android, these may differ // due to best-wake-sample optimization. diff --git a/src/WayfarerMobile/Services/LocationPipelineWiring.cs b/src/WayfarerMobile/Services/LocationPipelineWiring.cs new file mode 100644 index 00000000..6a4964fc --- /dev/null +++ b/src/WayfarerMobile/Services/LocationPipelineWiring.cs @@ -0,0 +1,267 @@ +using Microsoft.Extensions.Logging; +using WayfarerMobile.Core.Interfaces; +using WayfarerMobile.Core.Models; +using WayfarerMobile.Data.Repositories; +using WayfarerMobile.Helpers; + +namespace WayfarerMobile.Services; + +/// +/// Sole orchestrator of the location sync pipeline bootstrap. +/// Ensures delegates, drain services, and timeline storage are wired +/// exactly once, whether bootstrapped from App.xaml.cs (normal MAUI start) +/// or from LocationTrackingService (headless restart after process kill). +/// +/// +/// +/// Design invariants: +/// +/// +/// Guarded by Interlocked.CompareExchange - concurrent callers are safe. +/// On failure the guard resets, allowing retry on the next location fix. +/// This is safe because all downstream services (R3) are individually idempotent. +/// LocationTrackingService does not know WHAT gets bootstrapped - it calls one method. +/// +/// +public static class LocationPipelineWiring +{ + private static int _bootstrapGuard; + + /// + /// Resets the bootstrap guard to allow re-entry. For test use only. + /// + internal static void ResetForTesting() + { + Interlocked.Exchange(ref _bootstrapGuard, 0); + } + + /// + /// Bootstraps the sync pipeline idempotently. Safe to call from multiple threads. + /// + /// + /// DI service provider. If null, logs a warning and returns (no retry loop - + /// Android guarantees Application.onCreate() completes before Service.onCreate(), + /// so DI is available). + /// + public static async Task EnsureBootstrappedAsync(IServiceProvider? serviceProvider) + { + if (serviceProvider == null) + { +#if ANDROID + Android.Util.Log.Warn("WayfarerLocation", "LocationPipelineWiring: serviceProvider is null, skipping bootstrap"); +#endif + return; + } + + // Atomic guard: only one caller enters bootstrap + if (Interlocked.CompareExchange(ref _bootstrapGuard, 1, 0) != 0) + return; + + // Resolve logger from DI (available once we have a serviceProvider) + var logger = serviceProvider.GetService()?.CreateLogger(typeof(LocationPipelineWiring).FullName!); + + var delegatesWired = false; + var drainStarted = false; + + try + { + // 1. Resolve DI services + var settingsService = serviceProvider.GetService(); + var queueDrainService = serviceProvider.GetService(); + var timelineSyncService = serviceProvider.GetService(); + var apiClient = serviceProvider.GetService(); + var locationQueue = serviceProvider.GetService(); + var timelineStorageService = serviceProvider.GetService(); + + // 2. Preload secure settings (prevents deadlocks on Android SecureStorage) + if (settingsService != null) + { + await settingsService.PreloadSecureSettingsAsync(); + } + + // 3. Start QueueDrainService and TimelineSyncService (both idempotent via R3 guards) + if (queueDrainService != null) + { + await queueDrainService.StartAsync(); + } + + if (timelineSyncService != null) + { + await timelineSyncService.StartAsync(); + } + + drainStarted = true; + + // 4. Wire drain-loop starter delegate + if (queueDrainService != null) + { + Action drainLoopStarter = () => + { + queueDrainService.StartDrainLoop(); + timelineSyncService?.StartDrainLoop(); // Piggyback on location wakeups + }; + // Check both services: skip only if BOTH drain loops are already running. + // Each service has its own Interlocked guard, so invoking the starter + // when one is idle and the other is running is safe and desired. + Func isRunningChecker = () => + queueDrainService.IsDrainLoopRunning && + (timelineSyncService?.IsDrainLoopRunning ?? true); + +#if ANDROID + Platforms.Android.Services.LocationTrackingService.SetDrainLoopStarter(drainLoopStarter, isRunningChecker); +#elif IOS + Platforms.iOS.Services.LocationTrackingService.SetDrainLoopStarter(drainLoopStarter, isRunningChecker); +#endif + } + + // 5. Initialize LocalTimelineStorageService (idempotent via R3 guard on SubscribeToEvents) + if (timelineStorageService != null) + { + await timelineStorageService.InitializeAsync(); + } + + // 6. Build and set OnlineSubmitDelegate and OfflineQueueDelegate + if (apiClient != null && locationQueue != null) + { + WireLocationDelegates(apiClient, locationQueue, timelineStorageService); + delegatesWired = true; + } + + logger?.LogInformation( + "LocationPipelineWiring: bootstrap=success delegates={DelegatesWired} drain={DrainStarted}", + delegatesWired, drainStarted); + + // If delegates weren't wired (e.g. IApiClient or ILocationQueueRepository resolved to null), + // reset the guard so the next location fix can retry bootstrap. + if (!delegatesWired) + { + Interlocked.Exchange(ref _bootstrapGuard, 0); + logger?.LogWarning( + "LocationPipelineWiring: incomplete bootstrap (delegates not wired), will retry on next location fix"); + } + } + catch (Exception ex) + { + // Reset guard to allow retry on next location fix. + // Safe because all steps called above are individually idempotent (R3). + Interlocked.Exchange(ref _bootstrapGuard, 0); + + logger?.LogError(ex, + "LocationPipelineWiring: bootstrap=failed delegates={DelegatesWired} drain={DrainStarted} reason={Reason}", + delegatesWired, drainStarted, ex.Message); + } + } + + /// + /// Builds and sets the online/offline delegates on the platform LocationTrackingService. + /// + /// + /// Online submit delegate return semantics: + /// + /// Returns serverId (int): Server accepted the location. + /// Returns null: Server explicitly skipped (threshold not met) — don't queue. + /// Throws exception: Network or API failure — triggers offline queue fallback. + /// + /// Offline queue delegate: Queues for background sync and adds a + /// pending entry to the local timeline (updated when queue drains). + /// + private static void WireLocationDelegates( + IApiClient apiClient, + ILocationQueueRepository locationQueue, + LocalTimelineStorageService? timelineStorageService) + { + // Online submit delegate: Submit directly to server via log-location endpoint + // Return semantics: + // - Returns serverId: Server accepted the location + // - Returns null: Server explicitly skipped (threshold not met) - don't queue + // - Throws exception: Network or API failure - triggers offline queue fallback + Func> onlineSubmit = async location => + { + // Early connectivity check - avoids timeout delays when completely offline. + // Only block on NetworkAccess.None (no network interface at all). + // Allow Local/ConstrainedInternet to attempt the call for LAN-only server deployments. + // The IsTransient check below handles actual network failures. + if (Connectivity.Current.NetworkAccess == NetworkAccess.None) + throw new HttpRequestException("No network connectivity"); + + // Convert to API request model with metadata + var request = new LocationLogRequest + { + Latitude = location.Latitude, + Longitude = location.Longitude, + Accuracy = location.Accuracy, + Altitude = location.Altitude, + Speed = location.Speed, + Timestamp = location.Timestamp, + Provider = location.Provider, + Bearing = location.Bearing, + // Metadata fields - captured at submission time + Source = "mobile-log", + IsUserInvoked = false, + AppVersion = DeviceMetadataHelper.GetAppVersion(), + AppBuild = DeviceMetadataHelper.GetAppBuild(), + DeviceModel = DeviceMetadataHelper.GetDeviceModel(), + OsVersion = DeviceMetadataHelper.GetOsVersion(), + BatteryLevel = DeviceMetadataHelper.GetBatteryLevel(), + IsCharging = DeviceMetadataHelper.GetIsCharging() + }; + + // Call the log-location endpoint (server is authoritative) + var result = await apiClient.LogLocationAsync(request, idempotencyKey: null); + + // Case 1: Server accepted or skipped - don't queue + // Note: log-location API may return just { "success": true } without locationId + if (result.Success) + { + // Only update local timeline if server returned a locationId (not skipped) + if (!result.Skipped && result.LocationId.HasValue && timelineStorageService != null) + { + await timelineStorageService.AddAcceptedLocationAsync(location, result.LocationId.Value); + } + // Return locationId if available, null otherwise (either skipped or accepted without ID) + return result.LocationId; + } + + // Case 2: Transient failure - throw to trigger offline queue + // Check both IsTransient flag AND status code, since ApiClient may not set + // IsTransient for HTTP status failures (they come through with IsTransient=false). + // Transient codes: 408 (Request Timeout), 429 (Too Many Requests), 5xx (Server Error). + // QueueDrainService handles these appropriately with retry logic. + var isTransientStatusCode = result.StatusCode.HasValue && + (result.StatusCode == 408 || result.StatusCode == 429 || result.StatusCode >= 500); + + if (result.IsTransient || isTransientStatusCode) + throw new HttpRequestException($"Transient failure: {result.Message}"); + + // Case 3: Permanent API failure (4xx client errors) - return null, don't queue. + // These won't succeed on retry (bad request, unauthorized, etc.) and queueing them + // creates pending timeline entries that never clear since QueueDrainService's 4xx + // handling doesn't emit LocationSkipped events. + return null; + }; + + // Offline queue delegate: Queue for background sync + Func> offlineQueue = async location => + { + // Queue with isUserInvoked=false (background location) + var queuedId = await locationQueue.QueueLocationAsync(location, isUserInvoked: false); + + // Add pending entry to local timeline (will be updated when queue drains) + if (timelineStorageService != null) + { + await timelineStorageService.AddPendingLocationAsync(location, queuedId); + } + + return queuedId; + }; + + // Wire to platform services +#if ANDROID + Platforms.Android.Services.LocationTrackingService.OnlineSubmitDelegate = onlineSubmit; + Platforms.Android.Services.LocationTrackingService.OfflineQueueDelegate = offlineQueue; +#elif IOS + Platforms.iOS.Services.LocationTrackingService.OnlineSubmitDelegate = onlineSubmit; + Platforms.iOS.Services.LocationTrackingService.OfflineQueueDelegate = offlineQueue; +#endif + } +} diff --git a/src/WayfarerMobile/Services/QueueDrainService.cs b/src/WayfarerMobile/Services/QueueDrainService.cs index bbcc7d0c..ab9200e6 100644 --- a/src/WayfarerMobile/Services/QueueDrainService.cs +++ b/src/WayfarerMobile/Services/QueueDrainService.cs @@ -142,6 +142,7 @@ public sealed class QueueDrainService : IDisposable private volatile bool _isOnline; private volatile bool _isDisposed; private volatile bool _isStarted; + private int _startGuard; // For thread-safe StartAsync via Interlocked private int _disposeGuard; // For thread-safe Dispose via Interlocked private int _drainLoopRunning; // For thread-safe drain loop guard via Interlocked @@ -189,7 +190,8 @@ public async Task StartAsync() return; } - if (_isStarted) + // Atomic guard: only one caller can enter startup + if (Interlocked.CompareExchange(ref _startGuard, 1, 0) != 0) { _logger.LogDebug("QueueDrainService already started"); return; @@ -230,6 +232,8 @@ public async Task StartAsync() } catch (Exception ex) { + // Reset guard to allow retry on next bootstrap attempt + Interlocked.Exchange(ref _startGuard, 0); _logger.LogError(ex, "Failed to start QueueDrainService"); } } diff --git a/src/WayfarerMobile/Services/TimelineSyncService.cs b/src/WayfarerMobile/Services/TimelineSyncService.cs index c4eb6cf1..3c8c66ce 100644 --- a/src/WayfarerMobile/Services/TimelineSyncService.cs +++ b/src/WayfarerMobile/Services/TimelineSyncService.cs @@ -110,6 +110,7 @@ public sealed class TimelineSyncService : ITimelineSyncService private volatile bool _isDisposed; private volatile bool _isStarted; private bool _initialized; + private int _startGuard; // For thread-safe StartAsync via Interlocked private int _disposeGuard; // For thread-safe Dispose via Interlocked private int _drainLoopRunning; // For thread-safe drain loop guard via Interlocked @@ -190,7 +191,8 @@ public async Task StartAsync() return; } - if (_isStarted) + // Atomic guard: only one caller can enter startup + if (Interlocked.CompareExchange(ref _startGuard, 1, 0) != 0) { _logger.LogDebug("TimelineSyncService already started"); return; @@ -224,6 +226,8 @@ public async Task StartAsync() } catch (Exception ex) { + // Reset guard to allow retry on next bootstrap attempt + Interlocked.Exchange(ref _startGuard, 0); _logger.LogError(ex, "Failed to start TimelineSyncService"); } } diff --git a/src/WayfarerMobile/WayfarerMobile.csproj b/src/WayfarerMobile/WayfarerMobile.csproj index 5f230810..ec236dce 100644 --- a/src/WayfarerMobile/WayfarerMobile.csproj +++ b/src/WayfarerMobile/WayfarerMobile.csproj @@ -91,6 +91,11 @@ + + + + + diff --git a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs index 2ae2c3fa..364023c3 100644 --- a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs +++ b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs @@ -143,6 +143,9 @@ public void ClearSyncReference() #endregion + /// + public Task PreloadSecureSettingsAsync() => Task.CompletedTask; + #region Clear Methods public void Clear()