Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/WayfarerMobile.Core/Interfaces/ISettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,12 @@ public interface ISettingsService

#endregion

/// <summary>
/// Pre-loads secure settings from SecureStorage into memory cache.
/// Call this at app startup to avoid blocking on first access.
/// </summary>
Task PreloadSecureSettingsAsync();

/// <summary>
/// Clears all settings (for logout/reset).
/// </summary>
Expand Down
168 changes: 9 additions & 159 deletions src/WayfarerMobile/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -171,53 +169,20 @@ private void InitializeExceptionHandler()

/// <summary>
/// Starts background services for the application.
/// Sync pipeline (settings, drain, timeline, delegates) is handled by
/// <see cref="LocationPipelineWiring.EnsureBootstrappedAsync"/> which is idempotent
/// and also callable from the headless LocationTrackingService after process kill.
/// </summary>
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<ISettingsService>() 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<QueueDrainService>();
SafeFireAndForget(queueDrainService?.StartAsync(), "QueueDrainService");
_logger.LogDebug("Queue drain service started");

// Start timeline sync service (offline mutation sync)
var timelineSyncService = _serviceProvider.GetService<ITimelineSyncService>();
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<bool> 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<LocalTimelineStorageService>();
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
Expand All @@ -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.
}
Expand All @@ -244,117 +205,6 @@ private void StartBackgroundServices()
}
}

/// <summary>
/// Wires up the location tracking delegates for online/offline path decision.
/// Called early during startup to ensure delegates are set before location service starts.
/// </summary>
/// <param name="timelineStorageService">The local timeline storage service for updating timeline.</param>
private void WireLocationTrackingDelegates(LocalTimelineStorageService? timelineStorageService)
{
var apiClient = _serviceProvider.GetService<IApiClient>();
var locationQueue = _serviceProvider.GetService<ILocationQueueRepository>();

// 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<LocationData, Task<int?>> 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<LocationData, Task<int>> 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
}

/// <summary>
/// Starts the location tracking service if permissions are granted.
/// The service runs 24/7 with context switching between high/normal performance modes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions src/WayfarerMobile/Services/LocalTimelineStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class LocalTimelineStorageService : IDisposable
private readonly ILogger<LocalTimelineStorageService> _logger;
private bool _isInitialized;
private bool _disposed;
private int _eventsSubscribed; // Interlocked guard for SubscribeToEvents idempotency

/// <summary>
/// Time window for backfill/reconciliation operations (days).
Expand Down Expand Up @@ -340,6 +341,14 @@ private async Task BackfillMissedQueueEntriesAsync()
/// </summary>
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.
Expand Down
Loading
Loading