From c13ac1e8f26e14db8ce848dc44ffee3bad058ca4 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Feb 2026 19:03:31 +0200 Subject: [PATCH 1/5] WIP: fix #220 headless sync pipeline bootstrap (checkpoint) R3: Add Interlocked.CompareExchange guards to QueueDrainService.StartAsync(), TimelineSyncService.StartAsync(), and LocalTimelineStorageService.SubscribeToEvents() to prevent duplicate timers/subscriptions under concurrent or retry calls. R1: Extract LocationPipelineWiring as sole orchestrator of the sync pipeline bootstrap. Single method EnsureBootstrappedAsync() with atomic guard and reset-on-failure for transient errors. R2: Replace App.xaml.cs StartBackgroundServices() sync code with call to LocationPipelineWiring. Add bootstrap trigger in LocationTrackingService LogLocationToQueue() fallback path for headless restart after process kill. R4: Structured diagnostic log in LocationPipelineWiring. --- src/WayfarerMobile/App.xaml.cs | 168 +------------- .../Services/LocationTrackingService.cs | 9 +- .../Services/LocalTimelineStorageService.cs | 9 + .../Services/LocationPipelineWiring.cs | 211 ++++++++++++++++++ .../Services/QueueDrainService.cs | 6 +- .../Services/TimelineSyncService.cs | 6 +- 6 files changed, 246 insertions(+), 163 deletions(-) create mode 100644 src/WayfarerMobile/Services/LocationPipelineWiring.cs 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..17c354ff 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(() => WayfarerMobile.Services.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..08f89971 --- /dev/null +++ b/src/WayfarerMobile/Services/LocationPipelineWiring.cs @@ -0,0 +1,211 @@ +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; + + /// + /// 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() as SettingsService; + 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 + }; + Func isRunningChecker = () => queueDrainService.IsDrainLoopRunning; + +#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); + } + 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. + /// + private static void WireLocationDelegates( + IApiClient apiClient, + ILocationQueueRepository locationQueue, + LocalTimelineStorageService? timelineStorageService) + { + // Online submit delegate: Submit directly to server via log-location endpoint + Func> onlineSubmit = async location => + { + // Early connectivity check + if (Connectivity.Current.NetworkAccess == NetworkAccess.None) + throw new HttpRequestException("No network connectivity"); + + 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, + Source = "mobile-log", + IsUserInvoked = false, + AppVersion = DeviceMetadataHelper.GetAppVersion(), + AppBuild = DeviceMetadataHelper.GetAppBuild(), + DeviceModel = DeviceMetadataHelper.GetDeviceModel(), + OsVersion = DeviceMetadataHelper.GetOsVersion(), + BatteryLevel = DeviceMetadataHelper.GetBatteryLevel(), + IsCharging = DeviceMetadataHelper.GetIsCharging() + }; + + var result = await apiClient.LogLocationAsync(request, idempotencyKey: null); + + if (result.Success) + { + if (!result.Skipped && result.LocationId.HasValue && timelineStorageService != null) + { + await timelineStorageService.AddAcceptedLocationAsync(location, result.LocationId.Value); + } + return result.LocationId; + } + + 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}"); + + // Permanent API failure (4xx) - don't queue + return null; + }; + + // Offline queue delegate: Queue for background sync + Func> offlineQueue = async location => + { + var queuedId = await locationQueue.QueueLocationAsync(location, isUserInvoked: false); + + 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"); } } From ffd1860a77acac698972c7054940c0d818236403 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Feb 2026 19:06:37 +0200 Subject: [PATCH 2/5] Fix #220: bootstrap sync pipeline on headless service restart When Android kills the app process and restarts the sticky LocationTrackingService, MAUI never initializes, leaving the sync pipeline (delegates, drain services, timeline storage) null. Locations queue in SQLite but never drain until the user opens the app. Root cause: The sync pipeline was wired exclusively in App.xaml.cs during MAUI startup, with no recovery path for headless restarts. Changes: R3 - Idempotency guards: - QueueDrainService.StartAsync(): Interlocked.CompareExchange guard with reset-on-failure (replaces volatile bool check) - TimelineSyncService.StartAsync(): same pattern - LocalTimelineStorageService.SubscribeToEvents(): Interlocked guard prevents duplicate event handlers that would create duplicate timeline entries R1 - LocationPipelineWiring (new): - Static class as sole orchestrator of sync pipeline bootstrap - EnsureBootstrappedAsync() with atomic guard + reset-on-failure - Resolves DI services, preloads secure settings, starts drain/sync services, initializes timeline storage, wires location delegates - All downstream calls are idempotent (R3 prerequisite) R2 - Dual bootstrap call sites: - App.xaml.cs: replaced inline StartBackgroundServices/ WireLocationTrackingDelegates with single call to bootstrapper - LocationTrackingService.LogLocationToQueue(): fires bootstrap in fallback path (null delegates = headless restart). Current location goes through bare DB fallback; future locations use wired delegates once bootstrap completes. R4 - Diagnostic logging: - Single structured log per bootstrap: success/failed, delegates, drain status Fixes: #220 --- .../Platforms/Android/Services/LocationTrackingService.cs | 2 +- src/WayfarerMobile/Services/LocationPipelineWiring.cs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs b/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs index 17c354ff..cdd43eb3 100644 --- a/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs +++ b/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs @@ -1340,7 +1340,7 @@ private async void LogLocationToQueue(LocationData location) // 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(() => WayfarerMobile.Services.LocationPipelineWiring.EnsureBootstrappedAsync( + _ = Task.Run(async () => await WayfarerMobile.Services.LocationPipelineWiring.EnsureBootstrappedAsync( IPlatformApplication.Current?.Services)); if (_databaseService != null) diff --git a/src/WayfarerMobile/Services/LocationPipelineWiring.cs b/src/WayfarerMobile/Services/LocationPipelineWiring.cs index 08f89971..74ba7fd8 100644 --- a/src/WayfarerMobile/Services/LocationPipelineWiring.cs +++ b/src/WayfarerMobile/Services/LocationPipelineWiring.cs @@ -92,7 +92,12 @@ public static async Task EnsureBootstrappedAsync(IServiceProvider? serviceProvid queueDrainService.StartDrainLoop(); timelineSyncService?.StartDrainLoop(); // Piggyback on location wakeups }; - Func isRunningChecker = () => queueDrainService.IsDrainLoopRunning; + // 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); From ace738a5ddf7c290c2917a5173643da0bfe0803e Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Feb 2026 19:17:15 +0200 Subject: [PATCH 3/5] Fix PR #221 review: guard reset on partial bootstrap & interface promotion Reset _bootstrapGuard when delegates aren't wired so the next location fix can retry. Promote PreloadSecureSettingsAsync to ISettingsService to eliminate the silent SettingsService downcast. --- .../Interfaces/ISettingsService.cs | 6 ++++++ src/WayfarerMobile/Services/LocationPipelineWiring.cs | 11 ++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) 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/Services/LocationPipelineWiring.cs b/src/WayfarerMobile/Services/LocationPipelineWiring.cs index 74ba7fd8..96b6c9c7 100644 --- a/src/WayfarerMobile/Services/LocationPipelineWiring.cs +++ b/src/WayfarerMobile/Services/LocationPipelineWiring.cs @@ -58,7 +58,7 @@ public static async Task EnsureBootstrappedAsync(IServiceProvider? serviceProvid try { // 1. Resolve DI services - var settingsService = serviceProvider.GetService() as SettingsService; + var settingsService = serviceProvider.GetService(); var queueDrainService = serviceProvider.GetService(); var timelineSyncService = serviceProvider.GetService(); var apiClient = serviceProvider.GetService(); @@ -122,6 +122,15 @@ public static async Task EnsureBootstrappedAsync(IServiceProvider? serviceProvid 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) { From 0b9503c26848b3f564179bfd8f5151aef1dbd623 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Feb 2026 19:19:18 +0200 Subject: [PATCH 4/5] Add PreloadSecureSettingsAsync to MockSettingsService Implement the new ISettingsService interface member in the test mock to fix CI build failure. --- .../Infrastructure/Mocks/MockSettingsService.cs | 3 +++ 1 file changed, 3 insertions(+) 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() From b93716cb63bf45e1be6b21524e26ff7d3bb22b7e Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Feb 2026 19:30:20 +0200 Subject: [PATCH 5/5] Polish PR #221: restore docs, add test hook, cosmetic cleanup - Restore detailed inline comments in WireLocationDelegates (return semantics, connectivity rationale, transient vs permanent failures) that were stripped during the move from App.xaml.cs - Add internal ResetForTesting() to LocationPipelineWiring with InternalsVisibleTo for the test project - Simplify fully-qualified LocationPipelineWiring reference in LocationTrackingService (using directive already present) --- .../Services/LocationTrackingService.cs | 2 +- .../Services/LocationPipelineWiring.cs | 46 ++++++++++++++++++- src/WayfarerMobile/WayfarerMobile.csproj | 5 ++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs b/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs index cdd43eb3..ec32fbf5 100644 --- a/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs +++ b/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs @@ -1340,7 +1340,7 @@ private async void LogLocationToQueue(LocationData location) // 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 WayfarerMobile.Services.LocationPipelineWiring.EnsureBootstrappedAsync( + _ = Task.Run(async () => await LocationPipelineWiring.EnsureBootstrappedAsync( IPlatformApplication.Current?.Services)); if (_databaseService != null) diff --git a/src/WayfarerMobile/Services/LocationPipelineWiring.cs b/src/WayfarerMobile/Services/LocationPipelineWiring.cs index 96b6c9c7..6a4964fc 100644 --- a/src/WayfarerMobile/Services/LocationPipelineWiring.cs +++ b/src/WayfarerMobile/Services/LocationPipelineWiring.cs @@ -27,6 +27,14 @@ 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. /// @@ -147,18 +155,36 @@ public static async Task EnsureBootstrappedAsync(IServiceProvider? serviceProvid /// /// 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 + // 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, @@ -169,6 +195,7 @@ private static void WireLocationDelegates( Timestamp = location.Timestamp, Provider = location.Provider, Bearing = location.Bearing, + // Metadata fields - captured at submission time Source = "mobile-log", IsUserInvoked = false, AppVersion = DeviceMetadataHelper.GetAppVersion(), @@ -179,32 +206,47 @@ private static void WireLocationDelegates( 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}"); - // Permanent API failure (4xx) - don't queue + // 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); 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 @@ + + + + +