From 4f58b3290c2a3209dff941f7233079f66958b31c Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 01:21:29 +0300 Subject: [PATCH 1/7] WIP: prove public OSRM navigation contact (checkpoint; tests failing) --- .../Infrastructure/PreferencesStub.cs | 13 +++++ .../TripNavigationRoutingRemovalTests.cs | 52 +++++++++++++++++++ .../WayfarerMobile.Tests.csproj | 5 ++ 3 files changed, 70 insertions(+) create mode 100644 tests/WayfarerMobile.Tests/Infrastructure/PreferencesStub.cs create mode 100644 tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs diff --git a/tests/WayfarerMobile.Tests/Infrastructure/PreferencesStub.cs b/tests/WayfarerMobile.Tests/Infrastructure/PreferencesStub.cs new file mode 100644 index 0000000..fc3b229 --- /dev/null +++ b/tests/WayfarerMobile.Tests/Infrastructure/PreferencesStub.cs @@ -0,0 +1,13 @@ +using System.Collections.Concurrent; + +public static class Preferences +{ + private static readonly ConcurrentDictionary Values = new(); + + public static T Get(string key, T defaultValue) => + Values.TryGetValue(key, out var value) && value is T typed ? typed : defaultValue; + + public static void Set(string key, T value) => Values[key] = value; + + public static void Remove(string key) => Values.TryRemove(key, out _); +} diff --git a/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs new file mode 100644 index 0000000..d1dbe87 --- /dev/null +++ b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs @@ -0,0 +1,52 @@ +using System.Net; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using WayfarerMobile.Core.Interfaces; +using WayfarerMobile.Services; + +namespace WayfarerMobile.Tests.Unit.Services; + +public sealed class TripNavigationRoutingRemovalTests +{ + [Fact] + public async Task MapTargetNavigation_DoesNotContactPublicProvider_AndUsesDirectGuidance() + { + var transport = new RecordingRouteTransport(); + var navigation = new TripNavigationService( + NullLogger.Instance, + new OsrmRoutingService(new HttpClient(transport), NullLogger.Instance), + new RouteCacheService(NullLogger.Instance), + Mock.Of(), + new NavigationRouteBuilder(NullLogger.Instance), + Mock.Of()); + + var route = await navigation.CalculateRouteToCoordinatesAsync( + 37.9838, 23.7275, + 37.9715, 23.7267, + "Map target"); + + transport.RequestCount.Should().Be(0); + route.IsDirectRoute.Should().BeTrue(); + route.Waypoints.Should().HaveCount(2); + } + + private sealed class RecordingRouteTransport : HttpMessageHandler + { + public int RequestCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestCount++; + const string body = """ + {"code":"Ok","routes":[{"geometry":"_p~iF~ps|U_ulLnnqC_mqNvxq`@","distance":1200,"duration":900,"legs":[]}]} + """; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }); + } + } +} diff --git a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj index cb7fef8..4fee556 100644 --- a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj +++ b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj @@ -73,6 +73,11 @@ + + + + + From 79ac4cbe70a541cc509467cbaf7ff65667691666 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 01:33:18 +0300 Subject: [PATCH 2/7] Remove public OSRM routing and cache ownership --- README.md | 4 +- THIRD-PARTY-NOTICES.txt | 12 - docs/03-Features.md | 29 +- docs/04-Trips-and-Offline.md | 2 +- docs/08-FAQ.md | 5 +- docs/09-Developer-Guide.md | 2 +- docs/11-Architecture.md | 15 +- docs/12-Services.md | 119 +----- docs/13-API.md | 85 +--- .../Interfaces/ITripNavigationService.cs | 21 +- .../OsrmRoutingDecommissionMigration.cs | 26 ++ .../Models/NavigationRoute.cs | 2 +- .../Navigation/TripNavigationGraph.cs | 2 +- .../Data/Services/DatabaseService.cs | 24 +- src/WayfarerMobile/Helpers/PolylineDecoder.cs | 4 - .../Interfaces/INavigationRouteBuilder.cs | 44 +- src/WayfarerMobile/MauiProgram.cs | 9 - .../Services/AppDiagnosticService.cs | 72 +--- .../Services/NavigationRouteBuilder.cs | 166 +------- .../Services/OsrmRoutingService.cs | 375 ------------------ .../Services/RouteCacheService.cs | 198 --------- .../Services/TripNavigationService.cs | 190 +-------- .../ViewModels/ContextMenuViewModel.cs | 17 +- .../ViewModels/DiagnosticsViewModel.Queue.cs | 138 +++++++ .../ViewModels/DiagnosticsViewModel.cs | 184 +-------- .../ViewModels/MemberDetailsViewModel.cs | 20 +- src/WayfarerMobile/Views/DiagnosticsPage.xaml | 14 - .../Mocks/MockTripNavigationService.cs | 2 +- .../Infrastructure/PreferencesStub.cs | 13 - .../Unit/Helpers/PolylineDecoderTests.cs | 2 +- .../OsrmRoutingDecommissionMigrationTests.cs | 57 +++ .../TripNavigationRoutingRemovalTests.cs | 70 ++-- .../WayfarerMobile.Tests.csproj | 2 - 33 files changed, 361 insertions(+), 1564 deletions(-) create mode 100644 src/WayfarerMobile.Core/Migrations/OsrmRoutingDecommissionMigration.cs delete mode 100644 src/WayfarerMobile/Helpers/PolylineDecoder.cs delete mode 100644 src/WayfarerMobile/Services/OsrmRoutingService.cs delete mode 100644 src/WayfarerMobile/Services/RouteCacheService.cs create mode 100644 src/WayfarerMobile/ViewModels/DiagnosticsViewModel.Queue.cs delete mode 100644 tests/WayfarerMobile.Tests/Infrastructure/PreferencesStub.cs create mode 100644 tests/WayfarerMobile.Tests/Unit/Services/OsrmRoutingDecommissionMigrationTests.cs diff --git a/README.md b/README.md index 29e9a2c..8d8bdae 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Wayfarer Mobile is a privacy-first companion app for self-hosted Wayfarer server - **Offline-First Architecture**: Local SQLite storage with background sync, works without internet - **Smart Battery Usage**: Three-phase sleep/wake optimization for background tracking (~1-3% per hour) -- **Dual Navigation Modes**: Trip navigation (user segments → cached → OSRM → direct) and ad-hoc navigation (OSRM → direct) +- **Truthful Navigation**: Saved Trip Segment geometry when available, otherwise straight-line Direct guidance - **Queue Resilience**: Configurable queue limit (default 25,000), fast sync (12s/location), export to CSV/GeoJSON > **Map cache note**: OpenStreetMap tiles requested during interactive viewing are kept in a bounded live cache. Previously viewed tiles may remain usable while cached, but Trip downloads do not include or guarantee an offline basemap area. @@ -133,7 +133,7 @@ WayfarerMobile/ |----------|------------| | Framework | .NET 10 MAUI | | Maps | Mapsui 5.1 with OpenStreetMap tiles | -| Routing | OSRM (Open Source Routing Machine) | +| Navigation | Saved Segment geometry and Direct guidance | | UI Components | Syncfusion MAUI Toolkit (MIT) | | MVVM | CommunityToolkit.Mvvm | | Database | SQLite-net-pcl | diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt index a4b9002..2c8a8af 100644 --- a/THIRD-PARTY-NOTICES.txt +++ b/THIRD-PARTY-NOTICES.txt @@ -259,18 +259,6 @@ https://opendatacommons.org/licenses/odbl/ Note: This application uses a local tile cache to respect OpenStreetMap's fair use policy. See https://operations.osmfoundation.org/policies/tiles/ --------------------------------------------------------------------------------- - -OSRM (Open Source Routing Machine) ----------------------------------- -https://project-osrm.org/ -https://github.com/Project-OSRM/osrm-backend -Copyright (c) Project OSRM contributors - -Licensed under the BSD 2-Clause License. - -Note: Routing can be provided by an OSRM-compatible endpoint (demo or self-hosted). - ================================================================================ LICENSE TEXTS diff --git a/docs/03-Features.md b/docs/03-Features.md index 29b3dec..e639aca 100644 --- a/docs/03-Features.md +++ b/docs/03-Features.md @@ -299,9 +299,9 @@ From the main map, you can add your current location to the loaded trip as a new --- -## Turn-by-Turn Navigation +## Navigation -Navigate to destinations with intelligent routing that adapts to context. +Navigate with saved Trip Segment geometry or honest straight-line Direct guidance. Mobile does not contact a public routing provider. ### Navigation Contexts @@ -310,8 +310,8 @@ The app supports navigation in different contexts: | Context | Started From | Features | |---------|--------------|----------| | **Trip Navigation** | Trip sidebar → place | Uses trip segments, full route priority | -| **Group Navigation** | Groups → member | OSRM routing to member location | -| **Map Navigation** | Long-press on map | OSRM routing to any point | +| **Group Navigation** | Groups → member | Direct guidance to member location | +| **Map Navigation** | Long-press on map | Direct guidance to any point | ### Starting Trip Navigation @@ -343,32 +343,24 @@ Route calculation differs based on navigation context: | Priority | Source | When Used | |----------|--------|-----------| | 1 | **User Segments** | Trip has pre-defined route geometry | -| 2 | **Cached OSRM** | Valid cache exists (same dest, <50m origin, <5 min old) | -| 3 | **OSRM Fetch** | Online and no cache available | -| 4 | **Direct Route** | Offline fallback (straight-line with bearing) | +| 2 | **Direct Route** | Saved geometry is unavailable or invalid | **Ad-Hoc Navigation** (groups, map locations): | Priority | Source | When Used | |----------|--------|-----------| -| 1 | **OSRM Fetch** | Online route calculation | -| 2 | **Direct Route** | Offline fallback (straight-line with bearing) | +| 1 | **Direct Route** | Always; ad-hoc targets have no saved Segment geometry | -> **Note**: Ad-hoc navigation doesn't have user segments or route caching since there's no trip context. +> **Note**: Ad-hoc navigation does not have saved Segment geometry because there is no Trip context. **User Segments**: Routes you defined when planning the trip. These include the exact polyline geometry and are always preferred over calculated routes. -**Cached OSRM**: Previously fetched routes are cached and reused if: -- Same destination -- Origin within 50 meters of cached origin -- Less than 5 minutes old - -**OSRM Fetch**: Online route calculation from OSRM (Open Source Routing Machine). Supports walking, driving, and cycling profiles. Rate limited to 1 request per second. - -**Direct Route**: When offline and no cached route exists, shows straight-line navigation with: +**Direct Route**: When saved Segment geometry is unavailable or invalid, shows straight-line navigation with: - Cardinal direction (N, NE, E, etc.) - Distance to destination - Bearing-based heading +Direct is not road-aware or hosted turn-by-turn routing. Authenticated Wayfarer-hosted routing is planned separately and is not implemented yet. + ### External Maps Integration For any navigation, you can choose **External Maps** to hand off to: @@ -657,7 +649,6 @@ For troubleshooting, access detailed diagnostics: - **Location Queue**: Pending sync items - **Tile Cache**: Cache statistics - **Tracking**: Service status - - **Navigation**: Route cache info 3. Export diagnostic report for support --- diff --git a/docs/04-Trips-and-Offline.md b/docs/04-Trips-and-Offline.md index 7631dd2..67e602f 100644 --- a/docs/04-Trips-and-Offline.md +++ b/docs/04-Trips-and-Offline.md @@ -27,7 +27,7 @@ The live cache can be inspected and cleared from **Settings** > **Map Cache**. C ## Using Trip Content Offline -Without a network connection, downloaded Places, Segments, Areas, and Trip metadata remain available. Planned Segment geometry is preferred for navigation. A valid cached route can also be used; otherwise navigation provides an honest direct distance and bearing fallback when online routing is unavailable. +Without a network connection, downloaded Places, Segments, Areas, and Trip metadata remain available. Valid planned Segment geometry is preferred for navigation; otherwise navigation provides honest Direct distance and bearing guidance. Direct is straight-line guidance, not road-aware turn-by-turn routing. Timeline data, queued locations, pending mutations, authentication state, and ordinary synchronization are independent of Trip downloads and the interactive map cache. diff --git a/docs/08-FAQ.md b/docs/08-FAQ.md index aec8c83..fd1a810 100644 --- a/docs/08-FAQ.md +++ b/docs/08-FAQ.md @@ -183,8 +183,9 @@ Full trip creation (defining regions, creating route segments, detailed planning Navigation only shows detailed routes if: - The trip has segments defined (created on web) -- Or OSRM can calculate a route (requires internet) -- Without either, you get direct bearing/distance guidance +- Without valid saved Segment geometry, you get Direct bearing/distance guidance + +Direct guidance is a straight line, not road-aware turn-by-turn routing. Mobile does not contact a public routing provider. ### What's the 50-meter rule? diff --git a/docs/09-Developer-Guide.md b/docs/09-Developer-Guide.md index 03ab8ea..879409b 100644 --- a/docs/09-Developer-Guide.md +++ b/docs/09-Developer-Guide.md @@ -46,7 +46,7 @@ WayfarerMobile is a .NET MAUI cross-platform mobile application for location tra - **Background Location Tracking**: 24/7 location tracking using platform-native foreground services - **Offline Trip Content**: Store Trip metadata, Places, routes, Areas, and navigation data in SQLite -- **Turn-by-Turn Navigation**: OSRM-based routing with audio announcements +- **Navigation**: Offline saved Segment geometry and provider-independent Direct guidance with applicable audio announcements - **Group Location Sharing**: Real-time location sharing via Server-Sent Events (SSE) - **PIN Security**: Optional app lock with salted SHA256 PIN hashing - **Timeline History**: View and manage location history synchronized with the server diff --git a/docs/11-Architecture.md b/docs/11-Architecture.md index 5a19d37..5b5e6e7 100644 --- a/docs/11-Architecture.md +++ b/docs/11-Architecture.md @@ -398,7 +398,7 @@ public class SettingsService : ISettingsService | **API** | `ApiClient`, `GroupsService`, `GroupMemberManager` | | **Sync** | `QueueDrainService`, `TripSyncCoordinator`, `TimelineSyncService`, `SyncEventBus` | | **Maps** | `MapBuilder`, `LocationLayerService`, `TripLayerService`, `GroupLayerService`, `TimelineLayerService`, `DroppedPinLayerService` | -| **Navigation** | `TripNavigationService`, `OsrmRoutingService`, `RouteCacheService` | +| **Navigation** | `TripNavigationService`, `NavigationRouteBuilder` | | **Interactive map cache** | `WayfarerTileSource`, `LiveTileCacheService`, `LiveTileCacheRepository` | | **Trip** | `TripStateManager`, `TripContentService`, `TripMetadataBuilder`, `PlaceOperationsHandler`, `RegionOperationsHandler` | | **Timeline** | `TimelineDataService`, `LocalTimelineStorageService`, `MutationQueueService` | @@ -419,11 +419,6 @@ services.AddHttpClient("WayfarerApi", client => new MediaTypeWithQualityHeaderValue("application/json")); }); -services.AddHttpClient("Osrm", client => -{ - client.Timeout = TimeSpan.FromSeconds(30); - client.DefaultRequestHeaders.Add("User-Agent", "WayfarerMobile/1.0"); -}); ``` ## Navigation System @@ -432,10 +427,10 @@ services.AddHttpClient("Osrm", client => The `TripNavigationService` calculates routes with the following priority: -1. **User Segments**: Trip-defined routes with polyline geometry (always preferred) -2. **Cached OSRM**: Previously fetched route if still valid -3. **OSRM Fetch**: Online route from `router.project-osrm.org` -4. **Direct Route**: Straight line with bearing + distance (offline fallback) +1. **Saved Segment geometry**: Trip-defined geometry (always preferred when valid) +2. **Direct guidance**: Straight line with bearing and distance + +Mobile does not contact a public routing provider. Authenticated Wayfarer-hosted routing is future work and is not part of the current architecture. ### Navigation Graph diff --git a/docs/12-Services.md b/docs/12-Services.md index b889ee2..3d91c5a 100644 --- a/docs/12-Services.md +++ b/docs/12-Services.md @@ -89,9 +89,8 @@ This document provides detailed documentation for the key services in WayfarerMo | Service | Purpose | Rate Limit | |---------|---------|------------| -| `TripNavigationService` | Route calculation, turn-by-turn | N/A | -| `OsrmRoutingService` | OSRM API client | 1 req/second | -| `RouteCacheService` | Single-route session cache | N/A | +| `TripNavigationService` | Saved-geometry and Direct route calculation | N/A | +| `NavigationRouteBuilder` | Saved Segment and Direct route construction | N/A | | `NavigationAudioService` | Voice announcements | N/A | ### Data Services @@ -489,7 +488,7 @@ Manages the dropped pin marker for map long-press interactions. Stateless render **Source**: `src/WayfarerMobile/Services/TripNavigationService.cs` -Provides navigation with route calculation and progress tracking. Supports two modes: +Provides navigation with route calculation and progress tracking. Mobile makes no direct routing-provider request. ### Navigation Modes @@ -497,24 +496,21 @@ Provides navigation with route calculation and progress tracking. Supports two m - Used when navigating to a trip place - Has access to user-defined segments and trip context - Route priority: - 1. User Segments (trip-defined routes) - 2. Cached OSRM (valid cache) - 3. OSRM Fetch (online) - 4. Direct Route (offline fallback) + 1. Valid saved Segment geometry (trip-defined routes) + 2. Direct Route (straight-line fallback) **Ad-Hoc Navigation** (`CalculateRouteToCoordinatesAsync`): - Used for groups, map locations, any coordinates - No trip context available - Route priority: - 1. OSRM Fetch (online) - 2. Direct Route (offline fallback) + 1. Direct Route ```csharp // Trip navigation - uses full route priority chain var route = await _tripNavigationService.CalculateRouteToPlaceAsync( currentLat, currentLon, destinationPlaceId); -// Ad-hoc navigation - OSRM or direct only +// Ad-hoc navigation - Direct straight-line guidance var route = await _tripNavigationService.CalculateRouteToCoordinatesAsync( currentLat, currentLon, destLat, destLon, destName, profile: "foot"); ``` @@ -535,10 +531,9 @@ public class TripNavigationGraph ### Route Calculation ```csharp -public async Task CalculateRouteToPlaceAsync( +public NavigationRoute? CalculateRouteToPlace( double currentLat, double currentLon, - string destinationPlaceId, - bool fetchFromOsrm = true) + string destinationPlaceId) { // Priority 1: User-defined segment if (_currentGraph.IsWithinSegmentRoutingRange(currentLat, currentLon)) @@ -548,27 +543,13 @@ public async Task CalculateRouteToPlaceAsync( return BuildRouteFromPath(path, currentLat, currentLon); } - // Priority 2: Cached OSRM route - var cachedRoute = _routeCacheService.GetValidRoute(currentLat, currentLon, destinationPlaceId); - if (cachedRoute != null) - return BuildRouteFromCache(cachedRoute, ...); - - // Priority 3: OSRM fetch - if (fetchFromOsrm) - { - var osrmRoute = await _osrmService.GetRouteAsync(...); - if (osrmRoute != null) - { - _routeCacheService.SaveRoute(...); - return BuildRouteFromOsrm(osrmRoute, ...); - } - } - - // Priority 4: Direct route + // Priority 2: Direct route return BuildDirectRoute(currentLat, currentLon, destination); } ``` +Direct guidance is not road-aware or hosted turn-by-turn routing. Authenticated Wayfarer-hosted routing remains future work. + ### Navigation State ```csharp @@ -1057,82 +1038,6 @@ Manages activity types with server sync and local caching. --- -## OsrmRoutingService - -**Source**: `src/WayfarerMobile/Services/OsrmRoutingService.cs` - -OSRM (Open Source Routing Machine) API client for route calculation. - -### Configuration - -| Setting | Value | -|---------|-------| -| Base URL | `https://router.project-osrm.org` | -| Rate limit | 1 request/second | -| Timeout | 10 seconds | -| Profiles | foot, car, bike | - -### Rate Limiting - -```csharp -private static readonly TimeSpan MinRequestInterval = TimeSpan.FromSeconds(1.1); - -private static async Task EnforceRateLimitAsync() -{ - var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime; - if (timeSinceLastRequest < MinRequestInterval) - { - await Task.Delay(MinRequestInterval - timeSinceLastRequest); - } - _lastRequestTime = DateTime.UtcNow; -} -``` - -### Response - -```csharp -public class OsrmRouteResult -{ - public string Geometry { get; set; } // Encoded polyline - public double DistanceMeters { get; set; } - public double DurationSeconds { get; set; } - public List Steps { get; set; } // Turn instructions -} -``` - ---- - -## RouteCacheService - -**Source**: `src/WayfarerMobile/Services/RouteCacheService.cs` - -Single-route session cache stored in Preferences. Survives app restart. - -### Cache Validity - -A cached route is valid if: -- Same destination place ID -- Origin within **50 meters** of cached origin -- Less than **5 minutes** old - -### Storage - -```csharp -public class CachedRoute -{ - public string DestinationPlaceId { get; set; } - public string DestinationName { get; set; } - public double OriginLatitude { get; set; } - public double OriginLongitude { get; set; } - public string Geometry { get; set; } // Encoded polyline - public double DistanceMeters { get; set; } - public double DurationSeconds { get; set; } - public DateTime FetchedAtUtc { get; set; } -} -``` - ---- - ## Interactive OSM Map Cache `WayfarerTileSource` requests the canonical OpenStreetMap layer as the renderer pans and zooms. `LiveTileCacheService` serves fresh entries without HTTP, conditionally revalidates expired entries, and keeps the live cache bounded by least-recently-used cleanup. Distinct visible tiles are not globally serialized. diff --git a/docs/13-API.md b/docs/13-API.md index ff38ba6..cbf7ffa 100644 --- a/docs/13-API.md +++ b/docs/13-API.md @@ -26,11 +26,6 @@ services.AddHttpClient("WayfarerApi", client => new MediaTypeWithQualityHeaderValue("application/json")); }); -services.AddHttpClient("Osrm", client => -{ - client.Timeout = TimeSpan.FromSeconds(30); - client.DefaultRequestHeaders.Add("User-Agent", "WayfarerMobile/1.0"); -}); ``` ### Creating Requests @@ -480,85 +475,9 @@ public async Task> SendAsync(HttpRequestMessage request) } ``` -## OSRM Routing API - -The app uses the public OSRM demo server for route calculation when no user-defined segment exists. - -### Route Request - -**Endpoint**: `GET https://router.project-osrm.org/route/v1/{profile}/{coordinates}` - -**Parameters**: -| Parameter | Description | -|-----------|-------------| -| profile | `foot`, `car`, or `bike` | -| coordinates | `{lon1},{lat1};{lon2},{lat2}` | - -**Query Parameters**: -| Parameter | Value | Description | -|-----------|-------|-------------| -| overview | `full` | Return full route geometry | -| geometries | `polyline` | Encoded polyline format | -| steps | `false` | Don't return turn-by-turn steps | - -**Example**: -``` -GET https://router.project-osrm.org/route/v1/foot/-0.1278,51.5074;-0.1300,51.5100?overview=full&geometries=polyline -``` +## Mobile Routing Boundary -**Response**: -```json -{ - "code": "Ok", - "routes": [ - { - "geometry": "encoded_polyline", - "legs": [ - { - "distance": 450.5, - "duration": 324.0 - } - ], - "distance": 450.5, - "duration": 324.0 - } - ] -} -``` - -### Rate Limiting - -The OSRM demo server has rate limits: -- Maximum 1 request per second -- No API key required - -The `OsrmRoutingService` implements rate limiting: - -```csharp -private static readonly SemaphoreSlim _rateLimiter = new(1, 1); -private static DateTime _lastRequestTime = DateTime.MinValue; -private const int MinRequestIntervalMs = 1100; // Slightly over 1 second - -public async Task GetRouteAsync(...) -{ - await _rateLimiter.WaitAsync(); - try - { - var elapsed = DateTime.UtcNow - _lastRequestTime; - if (elapsed.TotalMilliseconds < MinRequestIntervalMs) - { - await Task.Delay(MinRequestIntervalMs - (int)elapsed.TotalMilliseconds); - } - - // Make request... - _lastRequestTime = DateTime.UtcNow; - } - finally - { - _rateLimiter.Release(); - } -} -``` +Mobile does not contact a public or commercial routing provider. Valid downloaded Trip Segment geometry remains available offline; otherwise navigation uses Direct straight-line distance and bearing guidance. Direct is not hosted turn-by-turn routing. Authenticated provider-neutral Wayfarer routing is future work and is not implemented yet. ## JSON Serialization diff --git a/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs b/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs index 51f77b6..a9244e2 100644 --- a/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs +++ b/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs @@ -9,9 +9,7 @@ namespace WayfarerMobile.Core.Interfaces; /// /// Navigation priority: /// 1. User-defined segments (from trip data) -/// 2. Cached OSRM route (if still valid - same destination, within 50m of origin, less than 5 min old) -/// 3. Fetched routes (from OSRM when online) -/// 4. Direct route (straight line with bearing/distance) +/// 2. Direct route (straight line with bearing/distance) /// public interface ITripNavigationService { @@ -58,8 +56,7 @@ public interface ITripNavigationService void UnloadTrip(); /// - /// Calculates a route to a specific place (synchronous, no OSRM fetch). - /// Use for full routing with OSRM support. + /// Calculates a route to a specific place using saved Segment geometry or Direct guidance. /// /// Current latitude. /// Current longitude. @@ -68,28 +65,24 @@ public interface ITripNavigationService NavigationRoute? CalculateRouteToPlace(double currentLat, double currentLon, string destinationPlaceId); /// - /// Calculates a route to a specific place with OSRM fetching support. + /// Calculates a route to a specific place using saved Segment geometry or Direct guidance. /// /// Current latitude. /// Current longitude. /// Destination place ID. - /// Whether to fetch route from OSRM if no segment exists. /// The calculated route or null if no route found. /// /// Navigation priority: /// 1. User-defined segments (always preferred) - /// 2. Cached OSRM route (if still valid) - /// 3. OSRM-fetched routes (if online and fetchFromOsrm is true) - /// 4. Direct route (straight line fallback) + /// 2. Direct route (straight-line fallback) /// Task CalculateRouteToPlaceAsync( double currentLat, double currentLon, - string destinationPlaceId, - bool fetchFromOsrm = true); + string destinationPlaceId); /// /// Calculates a route to arbitrary coordinates (not requiring a loaded trip). - /// Uses OSRM for routing when online, falls back to straight line when offline. + /// Builds Direct straight-line guidance without contacting a routing provider. /// /// Current latitude. /// Current longitude. @@ -97,7 +90,7 @@ public interface ITripNavigationService /// Destination longitude. /// Destination name for display. /// Routing profile (foot, car, bike). Default is foot. - /// The calculated route (OSRM or direct). + /// The Direct route. Task CalculateRouteToCoordinatesAsync( double currentLat, double currentLon, double destLat, double destLon, diff --git a/src/WayfarerMobile.Core/Migrations/OsrmRoutingDecommissionMigration.cs b/src/WayfarerMobile.Core/Migrations/OsrmRoutingDecommissionMigration.cs new file mode 100644 index 0000000..e03d099 --- /dev/null +++ b/src/WayfarerMobile.Core/Migrations/OsrmRoutingDecommissionMigration.cs @@ -0,0 +1,26 @@ +namespace WayfarerMobile.Core.Migrations; + +public interface ILegacyOsrmPreferenceState +{ + Task RemovePreferencesAsync(IReadOnlyCollection keys, CancellationToken cancellationToken); + Task RecordSchemaVersionAsync(int version, CancellationToken cancellationToken); +} + +/// Removes only the obsolete public-OSRM preference residue. +public static class OsrmRoutingDecommissionMigration +{ + public const int SchemaVersion = 9; + + public static IReadOnlyCollection ObsoletePreferenceKeys { get; } = + [ + "cached_osrm_route" + ]; + + public static async Task ApplyAsync(ILegacyOsrmPreferenceState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(state); + cancellationToken.ThrowIfCancellationRequested(); + await state.RemovePreferencesAsync(ObsoletePreferenceKeys, cancellationToken).ConfigureAwait(false); + await state.RecordSchemaVersionAsync(SchemaVersion, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/WayfarerMobile.Core/Models/NavigationRoute.cs b/src/WayfarerMobile.Core/Models/NavigationRoute.cs index 82ea761..8c01f45 100644 --- a/src/WayfarerMobile.Core/Models/NavigationRoute.cs +++ b/src/WayfarerMobile.Core/Models/NavigationRoute.cs @@ -31,7 +31,7 @@ public class NavigationRoute public TimeSpan EstimatedDuration { get; set; } /// - /// Gets or sets whether this is a direct/straight-line route (no OSRM data). + /// Gets or sets whether this is a direct/straight-line route rather than routed geometry. /// public bool IsDirectRoute { get; set; } diff --git a/src/WayfarerMobile.Core/Navigation/TripNavigationGraph.cs b/src/WayfarerMobile.Core/Navigation/TripNavigationGraph.cs index 7ff5a65..e72cd40 100644 --- a/src/WayfarerMobile.Core/Navigation/TripNavigationGraph.cs +++ b/src/WayfarerMobile.Core/Navigation/TripNavigationGraph.cs @@ -401,7 +401,7 @@ public enum NavigationEdgeType { /// User-defined segment from trip data. UserSegment, - /// Route fetched from third-party routing service (OSRM, etc.). + /// Provider-independent routed geometry. Fetched } diff --git a/src/WayfarerMobile/Data/Services/DatabaseService.cs b/src/WayfarerMobile/Data/Services/DatabaseService.cs index 6771207..a3e6b2d 100644 --- a/src/WayfarerMobile/Data/Services/DatabaseService.cs +++ b/src/WayfarerMobile/Data/Services/DatabaseService.cs @@ -20,12 +20,12 @@ namespace WayfarerMobile.Data.Services; /// - Live tile cache /// /// -public class DatabaseService : IAsyncDisposable, ILegacyRasterState, ISegmentWaypointMigrationState +public class DatabaseService : IAsyncDisposable, ILegacyRasterState, ISegmentWaypointMigrationState, ILegacyOsrmPreferenceState { #region Constants private const string DatabaseFilename = "wayfarer.db3"; - private const int CurrentSchemaVersion = 8; + private const int CurrentSchemaVersion = 9; private const string SchemaVersionKey = "db_schema_version"; private static readonly SQLiteOpenFlags DbFlags = @@ -157,6 +157,11 @@ private async Task RunMigrationsAsync() await SegmentWaypointMigration.ApplyAsync(this, CancellationToken.None); } + if (currentVersion < 9) + { + await OsrmRoutingDecommissionMigration.ApplyAsync(this, CancellationToken.None); + } + // Update schema version await SetSchemaVersionAsync(CurrentSchemaVersion); Console.WriteLine($"[DatabaseService] Migration complete. Schema version: {CurrentSchemaVersion}"); @@ -341,6 +346,21 @@ Task ISegmentWaypointMigrationState.RecordSchemaVersionAsync(int version, Cancel return SetSchemaVersionAsync(version); } + Task ILegacyOsrmPreferenceState.RemovePreferencesAsync( + IReadOnlyCollection keys, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var key in keys) Preferences.Remove(key); + return Task.CompletedTask; + } + + Task ILegacyOsrmPreferenceState.RecordSchemaVersionAsync(int version, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return SetSchemaVersionAsync(version); + } + private async Task TableExistsAsync(string tableName) => await _database!.ExecuteScalarAsync("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", tableName) > 0; diff --git a/src/WayfarerMobile/Helpers/PolylineDecoder.cs b/src/WayfarerMobile/Helpers/PolylineDecoder.cs deleted file mode 100644 index e21b413..0000000 --- a/src/WayfarerMobile/Helpers/PolylineDecoder.cs +++ /dev/null @@ -1,4 +0,0 @@ -// This file has been moved to WayfarerMobile.Core.Helpers.PolylineDecoder -// Keeping this forwarding class for backwards compatibility during transition - -global using PolylineDecoder = WayfarerMobile.Core.Helpers.PolylineDecoder; diff --git a/src/WayfarerMobile/Interfaces/INavigationRouteBuilder.cs b/src/WayfarerMobile/Interfaces/INavigationRouteBuilder.cs index 6628890..20f70fc 100644 --- a/src/WayfarerMobile/Interfaces/INavigationRouteBuilder.cs +++ b/src/WayfarerMobile/Interfaces/INavigationRouteBuilder.cs @@ -5,7 +5,7 @@ namespace WayfarerMobile.Interfaces; /// -/// Builds navigation routes from various sources (OSRM, cache, path, direct). +/// Builds navigation routes from saved Segment paths and Direct guidance. /// public interface INavigationRouteBuilder { @@ -22,48 +22,6 @@ NavigationRoute BuildFromSegmentPath( double startLat, double startLon, TripNavigationGraph graph); - /// - /// Builds a navigation route from cached OSRM route data. - /// - /// The cached route data. - /// Starting latitude. - /// Starting longitude. - /// The destination node. - /// The constructed navigation route. - NavigationRoute BuildFromCachedRoute( - CachedRoute cached, - double startLat, double startLon, - NavigationNode destination); - - /// - /// Builds a navigation route from an OSRM response to a navigation node. - /// - /// The OSRM route result. - /// Starting latitude. - /// Starting longitude. - /// The destination node. - /// The constructed navigation route. - NavigationRoute BuildFromOsrmResponse( - OsrmRouteResult osrm, - double startLat, double startLon, - NavigationNode destination); - - /// - /// Builds a navigation route from an OSRM response to coordinates. - /// - /// The OSRM route result. - /// Starting latitude. - /// Starting longitude. - /// Destination latitude. - /// Destination longitude. - /// Destination name for display. - /// The constructed navigation route. - NavigationRoute BuildFromOsrmCoordinates( - OsrmRouteResult osrm, - double startLat, double startLon, - double destLat, double destLon, - string destName); - /// /// Builds a direct route (straight line) to a navigation node. /// diff --git a/src/WayfarerMobile/MauiProgram.cs b/src/WayfarerMobile/MauiProgram.cs index 1abf400..2e9c84a 100644 --- a/src/WayfarerMobile/MauiProgram.cs +++ b/src/WayfarerMobile/MauiProgram.cs @@ -242,8 +242,6 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); // Stateless rendering // Routing Services - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); @@ -432,13 +430,6 @@ private static void ConfigureHttpClients(IServiceCollection services) client.DefaultRequestHeaders.UserAgent.ParseAdd("WayfarerMobile/1.0 (+https://github.com/stef-k/WayfarerMobile)"); }); - // Osrm - routing service with 30s timeout - services.AddHttpClient("Osrm", client => - { - client.Timeout = TimeSpan.FromSeconds(30); - client.DefaultRequestHeaders.Add("User-Agent", "WayfarerMobile/1.0"); - }); - // SSE - Server-Sent Events with isolated connection pool and long timeout // Uses HTTP/1.1 to ensure completely separate TCP connections from API calls // HTTP/2 multiplexing can cause SSE to block API requests on the same host diff --git a/src/WayfarerMobile/Services/AppDiagnosticService.cs b/src/WayfarerMobile/Services/AppDiagnosticService.cs index 7d602fd..6279a05 100644 --- a/src/WayfarerMobile/Services/AppDiagnosticService.cs +++ b/src/WayfarerMobile/Services/AppDiagnosticService.cs @@ -21,7 +21,6 @@ public class AppDiagnosticService private readonly ILocationQueueRepository _locationQueueRepository; private readonly LiveTileCacheService _liveTileCache; private readonly IPermissionsService _permissionsService; - private readonly RouteCacheService _routeCacheService; /// /// Initializes a new instance of the AppDiagnosticService class. @@ -32,8 +31,7 @@ public AppDiagnosticService( ISettingsService settingsService, ILocationQueueRepository locationQueueRepository, LiveTileCacheService liveTileCache, - IPermissionsService permissionsService, - RouteCacheService routeCacheService) + IPermissionsService permissionsService) { _logger = logger; _locationBridge = locationBridge; @@ -41,7 +39,6 @@ public AppDiagnosticService( _locationQueueRepository = locationQueueRepository; _liveTileCache = liveTileCache; _permissionsService = permissionsService; - _routeCacheService = routeCacheService; } #region Location Queue Diagnostics @@ -236,45 +233,6 @@ private static string CalculateTrackingHealth(bool foreground, bool background, #endregion - #region Navigation Diagnostics - - /// - /// Gets navigation and route cache diagnostics. - /// Note: Route cache doesn't expose raw cached route - only validates on retrieval. - /// - public Task GetNavigationDiagnosticsAsync() - { - try - { - // Note: RouteCacheService only validates and returns routes via GetValidRoute() - // which requires current location and destination. For diagnostics we just - // report that route caching is available. - return Task.FromResult(new NavigationDiagnostics - { - HasCachedRoute = false, // Cannot determine without location context - CachedRouteDestination = null, - CachedRouteWaypointCount = 0, - CachedRouteDistance = null, - CachedRouteDuration = null, - CachedRouteTimestamp = null, - CacheAgeSeconds = 0, - IsCacheValid = false - }); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Invalid state getting navigation diagnostics"); - return Task.FromResult(new NavigationDiagnostics()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting navigation diagnostics"); - return Task.FromResult(new NavigationDiagnostics()); - } - } - - #endregion - #region Full Report /// @@ -322,19 +280,6 @@ public async Task GenerateFullReportAsync() report.AppendLine($" Accuracy: {trackingDiag.LastLocationAccuracy:F1}m"); } - // Navigation - var navDiag = await GetNavigationDiagnosticsAsync(); - report.AppendLine("\nNAVIGATION:"); - report.AppendLine($" Has Cached Route: {navDiag.HasCachedRoute}"); - if (navDiag.HasCachedRoute) - { - report.AppendLine($" Destination: {navDiag.CachedRouteDestination}"); - report.AppendLine($" Waypoints: {navDiag.CachedRouteWaypointCount}"); - report.AppendLine($" Distance: {navDiag.CachedRouteDistance:F0}m"); - report.AppendLine($" Cache Age: {navDiag.CacheAgeSeconds:F0}s"); - report.AppendLine($" Cache Valid: {navDiag.IsCacheValid}"); - } - report.AppendLine(new string('=', 60)); return report.ToString(); } @@ -394,19 +339,4 @@ public class TrackingDiagnostics public string TrackingHealthStatus { get; set; } = "Unknown"; } -/// -/// Navigation diagnostic information. -/// -public class NavigationDiagnostics -{ - public bool HasCachedRoute { get; set; } - public string? CachedRouteDestination { get; set; } - public int CachedRouteWaypointCount { get; set; } - public double? CachedRouteDistance { get; set; } - public double? CachedRouteDuration { get; set; } - public DateTime? CachedRouteTimestamp { get; set; } - public double CacheAgeSeconds { get; set; } - public bool IsCacheValid { get; set; } -} - #endregion diff --git a/src/WayfarerMobile/Services/NavigationRouteBuilder.cs b/src/WayfarerMobile/Services/NavigationRouteBuilder.cs index 620a749..8329718 100644 --- a/src/WayfarerMobile/Services/NavigationRouteBuilder.cs +++ b/src/WayfarerMobile/Services/NavigationRouteBuilder.cs @@ -9,7 +9,7 @@ namespace WayfarerMobile.Services; /// -/// Builds navigation routes from various sources (OSRM, cache, path, direct). +/// Builds navigation routes from saved Segment paths and Direct guidance. /// public class NavigationRouteBuilder : INavigationRouteBuilder { @@ -83,170 +83,6 @@ public NavigationRoute BuildFromSegmentPath( return route; } - /// - public NavigationRoute BuildFromCachedRoute( - CachedRoute cached, - double startLat, double startLon, - NavigationNode destination) - { - var waypoints = new List(); - - // Decode the polyline to get all route points - var routePoints = PolylineDecoder.Decode(cached.Geometry); - - // Add start - waypoints.Add(new NavigationWaypoint - { - Latitude = startLat, - Longitude = startLon, - Name = "Current Location", - Type = WaypointType.Start - }); - - // Add intermediate route points (skip first and last as they're start/destination) - foreach (var point in routePoints.Skip(1).Take(routePoints.Count - 2)) - { - waypoints.Add(new NavigationWaypoint - { - Latitude = point.Latitude, - Longitude = point.Longitude, - Type = WaypointType.RoutePoint - }); - } - - // Add destination - waypoints.Add(new NavigationWaypoint - { - Latitude = destination.Latitude, - Longitude = destination.Longitude, - Name = destination.Name, - Type = WaypointType.Destination, - PlaceId = destination.Id - }); - - return new NavigationRoute - { - Waypoints = waypoints, - DestinationName = destination.Name, - TotalDistanceMeters = cached.DistanceMeters, - EstimatedDuration = TimeSpan.FromSeconds(cached.DurationSeconds) - }; - } - - /// - public NavigationRoute BuildFromOsrmResponse( - OsrmRouteResult osrm, - double startLat, double startLon, - NavigationNode destination) - { - var waypoints = new List(); - - // Decode the polyline to get all route points - var routePoints = PolylineDecoder.Decode(osrm.Geometry); - - // Add start - waypoints.Add(new NavigationWaypoint - { - Latitude = startLat, - Longitude = startLon, - Name = "Current Location", - Type = WaypointType.Start - }); - - // Add intermediate route points (skip first and last as they're start/destination) - foreach (var point in routePoints.Skip(1).Take(routePoints.Count - 2)) - { - waypoints.Add(new NavigationWaypoint - { - Latitude = point.Latitude, - Longitude = point.Longitude, - Type = WaypointType.RoutePoint - }); - } - - // Add destination - waypoints.Add(new NavigationWaypoint - { - Latitude = destination.Latitude, - Longitude = destination.Longitude, - Name = destination.Name, - Type = WaypointType.Destination, - PlaceId = destination.Id - }); - - return new NavigationRoute - { - Waypoints = waypoints, - DestinationName = destination.Name, - TotalDistanceMeters = osrm.DistanceMeters, - EstimatedDuration = TimeSpan.FromSeconds(osrm.DurationSeconds) - }; - } - - /// - public NavigationRoute BuildFromOsrmCoordinates( - OsrmRouteResult osrm, - double startLat, double startLon, - double destLat, double destLon, - string destName) - { - var waypoints = new List(); - - // Decode the polyline to get all route points - var routePoints = PolylineDecoder.Decode(osrm.Geometry); - - // Add start - waypoints.Add(new NavigationWaypoint - { - Latitude = startLat, - Longitude = startLon, - Name = "Current Location", - Type = WaypointType.Start - }); - - // Add intermediate route points (skip first and last as they're start/destination) - foreach (var point in routePoints.Skip(1).Take(routePoints.Count - 2)) - { - waypoints.Add(new NavigationWaypoint - { - Latitude = point.Latitude, - Longitude = point.Longitude, - Type = WaypointType.RoutePoint - }); - } - - // Add destination - waypoints.Add(new NavigationWaypoint - { - Latitude = destLat, - Longitude = destLon, - Name = destName, - Type = WaypointType.Destination - }); - - // Convert OSRM steps to NavigationSteps - var steps = osrm.Steps.Select(s => new NavigationStep - { - Instruction = s.Instruction, - DistanceMeters = s.DistanceMeters, - DurationSeconds = s.DurationSeconds, - ManeuverType = s.ManeuverType, - Latitude = s.Latitude, - Longitude = s.Longitude, - StreetName = s.StreetName - }).ToList(); - - return new NavigationRoute - { - Waypoints = waypoints, - Steps = steps, - DestinationName = destName, - TotalDistanceMeters = osrm.DistanceMeters, - EstimatedDuration = TimeSpan.FromSeconds(osrm.DurationSeconds), - IsDirectRoute = false - }; - } - /// public NavigationRoute BuildDirectRoute( double startLat, double startLon, diff --git a/src/WayfarerMobile/Services/OsrmRoutingService.cs b/src/WayfarerMobile/Services/OsrmRoutingService.cs deleted file mode 100644 index e4af090..0000000 --- a/src/WayfarerMobile/Services/OsrmRoutingService.cs +++ /dev/null @@ -1,375 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Logging; -using WayfarerMobile.Helpers; - -namespace WayfarerMobile.Services; - -/// -/// Service for fetching routes from OSRM (Open Source Routing Machine) public API. -/// Uses the demo server at router.project-osrm.org for route calculations. -/// -/// -/// Rate limit: 1 request per second (demo server policy). -/// No API key required. -/// Profiles: foot, car, bike. -/// -public class OsrmRoutingService -{ - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - - private const string BaseUrl = "https://router.project-osrm.org"; - private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); - private static DateTime _lastRequestTime = DateTime.MinValue; - private static readonly TimeSpan MinRequestInterval = TimeSpan.FromSeconds(1.1); // Slightly over 1s to be safe - - /// - /// Creates a new instance of OsrmRoutingService. - /// - /// The HTTP client. - /// The logger. - public OsrmRoutingService(HttpClient httpClient, ILogger logger) - { - _httpClient = httpClient; - _httpClient.Timeout = RequestTimeout; - _httpClient.DefaultRequestHeaders.Add("User-Agent", "WayfarerMobile/1.0"); - _logger = logger; - } - - /// - /// Fetches a route between two points. - /// - /// Origin latitude. - /// Origin longitude. - /// Destination latitude. - /// Destination longitude. - /// Routing profile (foot, car, bike). Default is foot. - /// The route result or null if failed. - public async Task GetRouteAsync( - double fromLat, double fromLon, - double toLat, double toLon, - string profile = "foot") - { - try - { - // Enforce rate limit - await EnforceRateLimitAsync(); - - // Build URL: /route/v1/{profile}/{lon},{lat};{lon},{lat} - // Note: OSRM uses lon,lat order (not lat,lon) - // steps=true for turn-by-turn instructions - var url = $"{BaseUrl}/route/v1/{profile}/{fromLon},{fromLat};{toLon},{toLat}" + - "?overview=full&geometries=polyline&steps=true"; - - _logger.LogDebug("Fetching OSRM route: {Url}", url); - - var response = await _httpClient.GetAsync(url); - - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("OSRM request failed with status {StatusCode}", response.StatusCode); - return null; - } - - var osrmResponse = await response.Content.ReadFromJsonAsync( - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - if (osrmResponse?.Code != "Ok" || osrmResponse.Routes == null || osrmResponse.Routes.Count == 0) - { - _logger.LogWarning("OSRM returned no routes. Code: {Code}", osrmResponse?.Code); - return null; - } - - var route = osrmResponse.Routes[0]; - - // Parse step instructions from legs - var steps = new List(); - if (route.Legs != null) - { - foreach (var leg in route.Legs) - { - if (leg.Steps == null) continue; - foreach (var step in leg.Steps) - { - steps.Add(new OsrmStepResult - { - Instruction = step.Maneuver?.Instruction ?? GenerateInstruction(step.Maneuver?.Type, step.Maneuver?.Modifier, step.Name), - DistanceMeters = step.Distance, - DurationSeconds = step.Duration, - ManeuverType = step.Maneuver?.Type ?? "unknown", - Modifier = step.Maneuver?.Modifier, - StreetName = step.Name, - Latitude = step.Maneuver?.Location?.Count > 1 ? step.Maneuver.Location[1] : 0, - Longitude = step.Maneuver?.Location?.Count > 0 ? step.Maneuver.Location[0] : 0 - }); - } - } - } - - _logger.LogInformation( - "OSRM route fetched: {Distance:F1}km, {Duration:F0}min, {Steps} steps", - route.Distance / 1000, - route.Duration / 60, - steps.Count); - - return new OsrmRouteResult - { - Geometry = route.Geometry, - DistanceMeters = route.Distance, - DurationSeconds = route.Duration, - Steps = steps, - Source = "osrm" - }; - } - catch (TaskCanceledException) - { - _logger.LogWarning("OSRM request timed out"); - return null; - } - catch (HttpRequestException ex) - { - _logger.LogNetworkWarningIfOnline("OSRM request failed (network error): {Message}", ex.Message); - return null; - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to parse OSRM response"); - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error fetching OSRM route"); - return null; - } - } - - /// - /// Enforces the rate limit by waiting if necessary. - /// - private static async Task EnforceRateLimitAsync() - { - var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime; - if (timeSinceLastRequest < MinRequestInterval) - { - var delay = MinRequestInterval - timeSinceLastRequest; - await Task.Delay(delay); - } - _lastRequestTime = DateTime.UtcNow; - } - - /// - /// Generates a human-readable instruction from maneuver type and modifier. - /// - private static string GenerateInstruction(string? type, string? modifier, string? streetName) - { - var street = string.IsNullOrEmpty(streetName) ? "" : $" onto {streetName}"; - - return type switch - { - "depart" => $"Head {modifier ?? "forward"}{street}", - "arrive" => "You have arrived", - "turn" => modifier switch - { - "left" => $"Turn left{street}", - "right" => $"Turn right{street}", - "slight left" => $"Bear left{street}", - "slight right" => $"Bear right{street}", - "sharp left" => $"Sharp left{street}", - "sharp right" => $"Sharp right{street}", - "uturn" => "Make a U-turn", - _ => $"Turn{street}" - }, - "continue" => $"Continue{street}", - "merge" => $"Merge{street}", - "fork" => modifier switch - { - "left" => $"Keep left{street}", - "right" => $"Keep right{street}", - _ => $"Continue{street}" - }, - "roundabout" => $"Enter roundabout{street}", - "rotary" => $"Enter rotary{street}", - "exit roundabout" or "exit rotary" => $"Exit{street}", - "end of road" => modifier switch - { - "left" => $"Turn left{street}", - "right" => $"Turn right{street}", - _ => $"Continue{street}" - }, - _ => $"Continue{street}" - }; - } -} - -/// -/// Result from OSRM route request. -/// -public class OsrmRouteResult -{ - /// - /// Gets or sets the encoded polyline geometry. - /// - public string Geometry { get; set; } = string.Empty; - - /// - /// Gets or sets the total distance in meters. - /// - public double DistanceMeters { get; set; } - - /// - /// Gets or sets the total duration in seconds. - /// - public double DurationSeconds { get; set; } - - /// - /// Gets or sets the turn-by-turn step instructions. - /// - public List Steps { get; set; } = new(); - - /// - /// Gets or sets the source identifier. - /// - public string Source { get; set; } = "osrm"; -} - -/// -/// A single step/instruction in the route. -/// -public class OsrmStepResult -{ - /// - /// Human-readable instruction text. - /// - public string Instruction { get; set; } = string.Empty; - - /// - /// Distance for this step in meters. - /// - public double DistanceMeters { get; set; } - - /// - /// Duration for this step in seconds. - /// - public double DurationSeconds { get; set; } - - /// - /// Maneuver type (turn, depart, arrive, etc.). - /// - public string ManeuverType { get; set; } = string.Empty; - - /// - /// Maneuver modifier (left, right, slight left, etc.). - /// - public string? Modifier { get; set; } - - /// - /// Street name for this step. - /// - public string? StreetName { get; set; } - - /// - /// Latitude where this maneuver occurs. - /// - public double Latitude { get; set; } - - /// - /// Longitude where this maneuver occurs. - /// - public double Longitude { get; set; } -} - -#region OSRM API Response Models - -/// -/// OSRM API response. -/// -internal class OsrmResponse -{ - [JsonPropertyName("code")] - public string? Code { get; set; } - - [JsonPropertyName("routes")] - public List? Routes { get; set; } -} - -/// -/// OSRM route in response. -/// -internal class OsrmRoute -{ - [JsonPropertyName("geometry")] - public string Geometry { get; set; } = string.Empty; - - [JsonPropertyName("distance")] - public double Distance { get; set; } - - [JsonPropertyName("duration")] - public double Duration { get; set; } - - [JsonPropertyName("legs")] - public List? Legs { get; set; } -} - -/// -/// OSRM leg (segment between waypoints). -/// -internal class OsrmLeg -{ - [JsonPropertyName("distance")] - public double Distance { get; set; } - - [JsonPropertyName("duration")] - public double Duration { get; set; } - - [JsonPropertyName("steps")] - public List? Steps { get; set; } -} - -/// -/// OSRM step (single instruction/maneuver). -/// -internal class OsrmStep -{ - [JsonPropertyName("distance")] - public double Distance { get; set; } - - [JsonPropertyName("duration")] - public double Duration { get; set; } - - [JsonPropertyName("name")] - public string? Name { get; set; } - - [JsonPropertyName("maneuver")] - public OsrmManeuver? Maneuver { get; set; } -} - -/// -/// OSRM maneuver details. -/// -internal class OsrmManeuver -{ - [JsonPropertyName("type")] - public string? Type { get; set; } - - [JsonPropertyName("modifier")] - public string? Modifier { get; set; } - - [JsonPropertyName("instruction")] - public string? Instruction { get; set; } - - /// - /// Location as [longitude, latitude]. - /// - [JsonPropertyName("location")] - public List? Location { get; set; } - - [JsonPropertyName("bearing_before")] - public double BearingBefore { get; set; } - - [JsonPropertyName("bearing_after")] - public double BearingAfter { get; set; } -} - -#endregion diff --git a/src/WayfarerMobile/Services/RouteCacheService.cs b/src/WayfarerMobile/Services/RouteCacheService.cs deleted file mode 100644 index c5f6913..0000000 --- a/src/WayfarerMobile/Services/RouteCacheService.cs +++ /dev/null @@ -1,198 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Logging; -using WayfarerMobile.Core.Algorithms; - -namespace WayfarerMobile.Services; - -/// -/// Service for caching the last fetched OSRM route. -/// Stores a single route in preferences that survives app restart. -/// -/// -/// Cache validity rules: -/// - Same destination place ID -/// - Current location within 50m of cached origin -/// - Route not older than configured max age (default 5 minutes) -/// -public class RouteCacheService -{ - private readonly ILogger _logger; - private const string CacheKey = "cached_osrm_route"; - - /// - /// Maximum distance from cached origin to consider cache valid (meters). - /// - private const double MaxOriginDistanceMeters = 50; - - /// - /// Maximum age of cached route before it's considered stale. - /// - private static readonly TimeSpan MaxCacheAge = TimeSpan.FromMinutes(5); - - private CachedRoute? _memoryCache; - - /// - /// Creates a new instance of RouteCacheService. - /// - public RouteCacheService(ILogger logger) - { - _logger = logger; - LoadFromPreferences(); - } - - /// - /// Attempts to get a valid cached route for the given parameters. - /// - /// Current latitude. - /// Current longitude. - /// Target place ID. - /// The cached route if valid, null otherwise. - public CachedRoute? GetValidRoute(double currentLat, double currentLon, string destinationPlaceId) - { - if (_memoryCache == null) - { - return null; - } - - // Check destination matches - if (_memoryCache.DestinationPlaceId != destinationPlaceId) - { - _logger.LogDebug("Cache miss: different destination"); - return null; - } - - // Check origin proximity - var distanceFromOrigin = GeoMath.CalculateDistance( - currentLat, currentLon, - _memoryCache.OriginLatitude, _memoryCache.OriginLongitude); - - if (distanceFromOrigin > MaxOriginDistanceMeters) - { - _logger.LogDebug("Cache miss: origin too far ({Distance:F0}m)", distanceFromOrigin); - return null; - } - - // Check age - var age = DateTime.UtcNow - _memoryCache.FetchedAtUtc; - if (age > MaxCacheAge) - { - _logger.LogDebug("Cache miss: too old ({Age:F1} minutes)", age.TotalMinutes); - return null; - } - - _logger.LogDebug("Cache hit: route to {Destination}", destinationPlaceId); - return _memoryCache; - } - - /// - /// Saves a route to the cache. - /// - /// The route to cache. - public void SaveRoute(CachedRoute route) - { - _memoryCache = route; - SaveToPreferences(); - _logger.LogDebug("Cached route to {Destination}", route.DestinationPlaceId); - } - - /// - /// Clears the cached route. - /// - public void Clear() - { - _memoryCache = null; - Preferences.Remove(CacheKey); - _logger.LogDebug("Route cache cleared"); - } - - /// - /// Loads the cached route from preferences. - /// - private void LoadFromPreferences() - { - try - { - var json = Preferences.Get(CacheKey, string.Empty); - if (!string.IsNullOrEmpty(json)) - { - _memoryCache = JsonSerializer.Deserialize(json); - _logger.LogDebug("Loaded cached route from preferences"); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load cached route from preferences"); - _memoryCache = null; - } - } - - /// - /// Saves the cached route to preferences. - /// - private void SaveToPreferences() - { - try - { - if (_memoryCache != null) - { - var json = JsonSerializer.Serialize(_memoryCache); - Preferences.Set(CacheKey, json); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to save cached route to preferences"); - } - } -} - -/// -/// Represents a cached route from OSRM or other routing service. -/// -public class CachedRoute -{ - /// - /// Gets or sets the destination place ID. - /// - public string DestinationPlaceId { get; set; } = string.Empty; - - /// - /// Gets or sets the destination place name. - /// - public string DestinationName { get; set; } = string.Empty; - - /// - /// Gets or sets the origin latitude when route was fetched. - /// - public double OriginLatitude { get; set; } - - /// - /// Gets or sets the origin longitude when route was fetched. - /// - public double OriginLongitude { get; set; } - - /// - /// Gets or sets the encoded polyline geometry. - /// - public string Geometry { get; set; } = string.Empty; - - /// - /// Gets or sets the total distance in meters. - /// - public double DistanceMeters { get; set; } - - /// - /// Gets or sets the estimated duration in seconds. - /// - public double DurationSeconds { get; set; } - - /// - /// Gets or sets the routing service source (e.g., "osrm"). - /// - public string Source { get; set; } = "osrm"; - - /// - /// Gets or sets when the route was fetched (UTC). - /// - public DateTime FetchedAtUtc { get; set; } -} diff --git a/src/WayfarerMobile/Services/TripNavigationService.cs b/src/WayfarerMobile/Services/TripNavigationService.cs index d4c9c6f..44fd3cf 100644 --- a/src/WayfarerMobile/Services/TripNavigationService.cs +++ b/src/WayfarerMobile/Services/TripNavigationService.cs @@ -11,20 +11,16 @@ namespace WayfarerMobile.Services; /// /// Service for trip-based navigation using the local routing graph. -/// Provides route calculation, progress tracking, and rerouting. +/// Provides saved-geometry and Direct route calculation, progress tracking, and rerouting. /// /// /// Navigation priority: /// 1. User-defined segments (from trip data) -/// 2. Cached OSRM route (if still valid - same destination, within 50m of origin, < 5 min old) -/// 3. Fetched routes (from OSRM when online) -/// 4. Direct route (straight line with bearing/distance) +/// 2. Direct route (straight line with bearing/distance) /// public class TripNavigationService : ITripNavigationService { private readonly ILogger _logger; - private readonly OsrmRoutingService _osrmService; - private readonly RouteCacheService _routeCacheService; private readonly INavigationAudioService _audioService; private readonly INavigationRouteBuilder _routeBuilder; private readonly ITripStateManager _tripStateManager; @@ -76,29 +72,21 @@ public class TripNavigationService : ITripNavigationService /// Creates a new instance of TripNavigationService. /// /// The logger. - /// The OSRM routing service. - /// The route cache service. /// The navigation audio service. /// The navigation route builder. /// The trip state manager for fresh place data. public TripNavigationService( ILogger logger, - OsrmRoutingService osrmService, - RouteCacheService routeCacheService, INavigationAudioService audioService, INavigationRouteBuilder routeBuilder, ITripStateManager tripStateManager) { ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(osrmService); - ArgumentNullException.ThrowIfNull(routeCacheService); ArgumentNullException.ThrowIfNull(audioService); ArgumentNullException.ThrowIfNull(routeBuilder); ArgumentNullException.ThrowIfNull(tripStateManager); _logger = logger; - _osrmService = osrmService; - _routeCacheService = routeCacheService; _audioService = audioService; _routeBuilder = routeBuilder; _tripStateManager = tripStateManager; @@ -142,8 +130,7 @@ public void UnloadTrip() } /// - /// Calculates a route to a specific place (synchronous, no OSRM fetch). - /// Use for full routing with OSRM support. + /// Calculates a route to a specific place using saved Segment geometry or Direct guidance. /// /// Current latitude. /// Current longitude. @@ -185,118 +172,32 @@ public void UnloadTrip() } } - // Priority 3: Direct navigation (bearing + distance) + // Priority 2: Direct navigation (bearing + distance) _activeRoute = _routeBuilder.BuildDirectRoute(currentLat, currentLon, destination); _logger.LogDebug("Using direct route to {Destination}", destination.Name); return _activeRoute; } /// - /// Calculates a route to a specific place with OSRM fetching support. + /// Calculates a route to a specific place using saved Segment geometry or Direct guidance. /// /// Current latitude. /// Current longitude. /// Destination place ID. - /// Whether to fetch route from OSRM if no segment exists. /// The calculated route or null if no route found. /// /// Navigation priority: /// 1. User-defined segments (always preferred) - /// 2. Cached OSRM route (if still valid) - /// 3. OSRM-fetched routes (if online and fetchFromOsrm is true) - /// 4. Direct route (straight line fallback) + /// 2. Direct route (straight-line fallback) /// - public async Task CalculateRouteToPlaceAsync( + public Task CalculateRouteToPlaceAsync( double currentLat, double currentLon, - string destinationPlaceId, - bool fetchFromOsrm = true) - { - if (_currentGraph == null) - { - _logger.LogWarning("No trip loaded for navigation"); - return null; - } - - // Issue #191: Get fresh place data from TripStateManager to ensure we use - // current coordinates/name after edits, not stale cached graph data - var destination = GetFreshPlaceAsNode(destinationPlaceId); - if (destination == null) - { - _logger.LogWarning("Destination place {PlaceId} not found in trip", destinationPlaceId); - return null; - } - - _destinationPlaceId = destinationPlaceId; - _announcedStepKeys.Clear(); // Reset announcements for new route - - // Priority 1: Check for user-defined segment route - if (_currentGraph.IsWithinSegmentRoutingRange(currentLat, currentLon)) - { - var nearestNode = _currentGraph.FindNearestNode(currentLat, currentLon); - if (nearestNode != null) - { - var path = _currentGraph.FindPath(nearestNode.Id, destinationPlaceId); - if (path.Count > 0) - { - _activeRoute = _routeBuilder.BuildFromSegmentPath(path, currentLat, currentLon, _currentGraph); - _logger.LogDebug("Using segment route with {WaypointCount} waypoints", _activeRoute?.Waypoints.Count); - return _activeRoute; - } - } - } - - // Priority 2: Check for valid cached route - var cachedRoute = _routeCacheService.GetValidRoute(currentLat, currentLon, destinationPlaceId); - if (cachedRoute != null) - { - _activeRoute = _routeBuilder.BuildFromCachedRoute(cachedRoute, currentLat, currentLon, destination); - _logger.LogInformation( - "Using cached route to {Destination}: {Distance:F1}km", - destination.Name, cachedRoute.DistanceMeters / 1000); - return _activeRoute; - } - - // Priority 3: Try OSRM if enabled - if (fetchFromOsrm) - { - var osrmRoute = await _osrmService.GetRouteAsync( - currentLat, currentLon, - destination.Latitude, destination.Longitude, - "foot"); // Default to walking - - if (osrmRoute != null) - { - // Cache the fetched route - _routeCacheService.SaveRoute(new CachedRoute - { - DestinationPlaceId = destinationPlaceId, - DestinationName = destination.Name, - OriginLatitude = currentLat, - OriginLongitude = currentLon, - Geometry = osrmRoute.Geometry, - DistanceMeters = osrmRoute.DistanceMeters, - DurationSeconds = osrmRoute.DurationSeconds, - Source = osrmRoute.Source, - FetchedAtUtc = DateTime.UtcNow - }); - - _activeRoute = _routeBuilder.BuildFromOsrmResponse(osrmRoute, currentLat, currentLon, destination); - _logger.LogInformation( - "Using OSRM route to {Destination}: {Distance:F1}km", - destination.Name, osrmRoute.DistanceMeters / 1000); - return _activeRoute; - } - } - - // Priority 4: Direct navigation (bearing + distance) - _activeRoute = _routeBuilder.BuildDirectRoute(currentLat, currentLon, destination); - _logger.LogDebug("Using direct route to {Destination}", destination.Name); - return _activeRoute; - } + string destinationPlaceId) + => Task.FromResult(CalculateRouteToPlace(currentLat, currentLon, destinationPlaceId)); /// /// Calculates a route to arbitrary coordinates (not requiring a loaded trip). - /// Uses OSRM for routing when online, falls back to straight line when offline. + /// Direct guidance is a straight line with distance, bearing, and profile-aware ETA. /// /// Current latitude. /// Current longitude. @@ -304,42 +205,21 @@ public void UnloadTrip() /// Destination longitude. /// Destination name for display. /// Routing profile (foot, car, bike). Default is foot. - /// The calculated route (OSRM or direct). - public async Task CalculateRouteToCoordinatesAsync( + /// The Direct route. + public Task CalculateRouteToCoordinatesAsync( double currentLat, double currentLon, double destLat, double destLon, string destName, string profile = "foot") { - _logger.LogInformation("Calculating route to {Name} at {Lat},{Lon}", destName, destLat, destLon); + _logger.LogInformation("Calculating Direct guidance to {Name}", destName); _announcedStepKeys.Clear(); // Reset announcements for new route - // Try OSRM first - try - { - var osrmRoute = await _osrmService.GetRouteAsync( - currentLat, currentLon, - destLat, destLon, - profile); - - if (osrmRoute != null) - { - var route = _routeBuilder.BuildFromOsrmCoordinates(osrmRoute, currentLat, currentLon, destLat, destLon, destName); - _logger.LogInformation("Using OSRM route to {Name}: {Distance:F1}km", destName, osrmRoute.DistanceMeters / 1000); - _activeRoute = route; - return route; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "OSRM routing failed, falling back to direct route"); - } - - // Fallback to direct route (straight line) with profile-aware ETA + // Direct route (straight line) with profile-aware ETA _logger.LogInformation("Using direct route to {Name} with profile {Profile}", destName, profile); var directRoute = _routeBuilder.BuildDirectRouteToCoordinates(currentLat, currentLon, destLat, destLon, destName, profile); _activeRoute = directRoute; - return directRoute; + return Task.FromResult(directRoute); } /// @@ -451,13 +331,6 @@ private void CheckForTurnAnnouncement(double lat, double lon, TripNavigationStat if (timeSinceLastAnnouncement.TotalSeconds < MinAnnouncementIntervalSeconds) return; - // For routes with OSRM steps, use step-based announcements - if (_activeRoute.Steps.Count > 0 && !_activeRoute.IsDirectRoute) - { - CheckForStepAnnouncement(lat, lon); - return; - } - // For direct routes or routes without steps, use waypoint-based announcements // Find next waypoint with a name (places, not route points) var nextWaypoint = _activeRoute.Waypoints @@ -483,37 +356,6 @@ private void CheckForTurnAnnouncement(double lat, double lon, TripNavigationStat } } - /// - /// Checks for step-based turn announcements (OSRM routes). - /// - private void CheckForStepAnnouncement(double lat, double lon) - { - if (_activeRoute?.Steps == null || _activeRoute.Steps.Count == 0) - return; - - // Find the next step that we're approaching - foreach (var step in _activeRoute.Steps) - { - // Skip "arrive" steps - those are announced differently - if (step.ManeuverType == "arrive") - continue; - - var distance = GeoMath.CalculateDistance(lat, lon, step.Latitude, step.Longitude); - - // Announce when within announcement range but not too close - if (distance > 20 && distance <= TurnAnnouncementDistanceMeters) - { - var stepKey = $"{step.ManeuverType}:{step.Latitude:F5},{step.Longitude:F5}"; - // Only announce each step once per navigation session - if (_announcedStepKeys.Add(stepKey)) - { - AnnounceInstruction(step.Instruction, distance); - return; - } - } - } - } - /// /// Gets the transport mode for reaching a waypoint. /// @@ -781,7 +623,7 @@ private string GetCurrentInstruction(double lat, double lon) return _activeRoute.Steps.FirstOrDefault(); } - // For OSRM routes, find the nearest step we haven't passed yet + // Find the nearest step we haven't passed yet. NavigationStep? currentStep = null; double minDistance = double.MaxValue; diff --git a/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs b/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs index 4adbbe0..0fa5ee5 100644 --- a/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs +++ b/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs @@ -210,8 +210,7 @@ private async Task NavigateToContextLocationAsync() return; } - // Map selection to OSRM profile - var osrmProfile = navMethod switch + var travelProfile = navMethod switch { NavigationMethod.Walk => "foot", NavigationMethod.Drive => "car", @@ -223,14 +222,14 @@ private async Task NavigateToContextLocationAsync() { _callbacks.IsBusy = true; - // Calculate route using OSRM with straight line fallback + // Direct guidance uses the selected travel mode only for its ETA. var route = await _callbacks.CalculateRouteToCoordinatesAsync( currentLocation.Latitude, currentLocation.Longitude, ContextMenuLatitude, ContextMenuLongitude, "Dropped Pin", - osrmProfile); + travelProfile); // Clear dropped pin and start navigation ClearDroppedPin(); @@ -240,16 +239,6 @@ private async Task NavigateToContextLocationAsync() _logger.LogInformation("Started navigation to dropped pin: {Distance:F1}km", route.TotalDistanceMeters / 1000); } - catch (HttpRequestException ex) - { - _logger.LogNetworkWarningIfOnline("Network error calculating route: {Message}", ex.Message); - await _callbacks.ToastService.ShowErrorAsync("Network error. Please check your connection."); - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - _logger.LogError(ex, "Route calculation timed out"); - await _callbacks.ToastService.ShowErrorAsync("Request timed out. Please try again."); - } catch (Exception ex) { _logger.LogError(ex, "Failed to start navigation"); diff --git a/src/WayfarerMobile/ViewModels/DiagnosticsViewModel.Queue.cs b/src/WayfarerMobile/ViewModels/DiagnosticsViewModel.Queue.cs new file mode 100644 index 0000000..b738213 --- /dev/null +++ b/src/WayfarerMobile/ViewModels/DiagnosticsViewModel.Queue.cs @@ -0,0 +1,138 @@ +using System.Text; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using SQLite; +using WayfarerMobile.Services; + +namespace WayfarerMobile.ViewModels; + +/// Owns read-only location-queue diagnostics presentation. +public partial class DiagnosticsViewModel +{ + [ObservableProperty] + private string _queueHealthStatus = "Unknown"; + + [ObservableProperty] + private int _pendingLocations; + + [ObservableProperty] + private int _retryingLocations; + + [ObservableProperty] + private int _syncedLocations; + + [ObservableProperty] + private int _rejectedLocations; + + [ObservableProperty] + private string _oldestPendingAge = "N/A"; + + [ObservableProperty] + private string _lastSyncTime = "Never"; + + [ObservableProperty] + private string _queueDetails = "No queue data"; + + [RelayCommand] + private async Task RefreshQueueAsync() + { + try + { + var queueDiagnostics = await _appDiagnosticService.GetLocationQueueDiagnosticsAsync(); + UpdateLocationQueue(queueDiagnostics); + await LoadQueueDetailsAsync(); + await _toastService.ShowSuccessAsync("Queue refreshed"); + } + catch (SQLiteException ex) + { + _logger.LogError(ex, "Database error refreshing queue"); + await _toastService.ShowErrorAsync("Database error refreshing queue"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing queue"); + await _toastService.ShowErrorAsync("Failed to refresh queue"); + } + } + + private async Task LoadQueueDetailsAsync() + { + try + { + var locations = await _locationQueueRepository.GetAllQueuedLocationsAsync(); + if (locations.Count == 0) + { + QueueDetails = "Queue is empty"; + return; + } + + var recentLocations = locations.OrderByDescending(location => location.Timestamp).Take(50).ToList(); + var details = new StringBuilder(); + details.AppendLine($"Showing {recentLocations.Count} of {locations.Count} entries (newest first)"); + details.AppendLine(new string('-', 60)); + + foreach (var location in recentLocations) + { + AppendQueueLocation(details, location); + } + + QueueDetails = details.ToString(); + } + catch (SQLiteException ex) + { + _logger.LogError(ex, "Database error loading queue details"); + QueueDetails = "Database error loading queue details"; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading queue details"); + QueueDetails = $"Error loading queue details: {ex.Message}"; + } + } + + private static void AppendQueueLocation(StringBuilder details, Data.Entities.QueuedLocation location) + { + var status = location.SyncStatus switch + { + Core.Enums.SyncStatus.Pending => location.IsRejected ? "REJECTED" : + location.SyncAttempts > 0 ? $"RETRY({location.SyncAttempts})" : "PENDING", + Core.Enums.SyncStatus.Syncing => "SYNCING", + Core.Enums.SyncStatus.Synced => "SYNCED", + _ => "?" + }; + var userTag = location.IsUserInvoked ? " [USER]" : ""; + var invariant = System.Globalization.CultureInfo.InvariantCulture; + details.AppendLine($"[{location.Timestamp:HH:mm:ss}] {status}{userTag}"); + details.AppendLine($" Loc: {location.Latitude.ToString("F5", invariant)}, {location.Longitude.ToString("F5", invariant)}"); + if (location.Accuracy.HasValue) + details.Append($" Acc: {location.Accuracy.Value.ToString("F0", invariant)}m"); + if (location.Speed.HasValue) + details.Append($" Spd: {location.Speed.Value.ToString("F1", invariant)}m/s"); + if (location.Accuracy.HasValue || location.Speed.HasValue) + details.AppendLine(); + if (!string.IsNullOrEmpty(location.CheckInNotes)) + details.AppendLine($" Notes: {location.CheckInNotes}"); + if (!string.IsNullOrEmpty(location.LastError)) + details.AppendLine($" Err: {location.LastError}"); + details.AppendLine(); + } + + private void UpdateLocationQueue(LocationQueueDiagnostics diagnostics) + { + QueueHealthStatus = diagnostics.QueueHealthStatus; + PendingLocations = diagnostics.PendingCount; + RetryingLocations = diagnostics.RetryingCount; + SyncedLocations = diagnostics.SyncedCount; + RejectedLocations = diagnostics.RejectedCount; + OldestPendingAge = FormatAge(diagnostics.OldestPendingTimestamp); + LastSyncTime = diagnostics.LastSyncedTimestamp?.ToLocalTime().ToString("g") ?? "Never"; + } + + private static string FormatAge(DateTime? timestamp) + { + if (!timestamp.HasValue) return "N/A"; + var age = DateTime.UtcNow - timestamp.Value; + return age.TotalHours >= 1 ? $"{age.TotalHours:F1} hours" : $"{age.TotalMinutes:F0} min"; + } +} diff --git a/src/WayfarerMobile/ViewModels/DiagnosticsViewModel.cs b/src/WayfarerMobile/ViewModels/DiagnosticsViewModel.cs index 09564a3..af6cd65 100644 --- a/src/WayfarerMobile/ViewModels/DiagnosticsViewModel.cs +++ b/src/WayfarerMobile/ViewModels/DiagnosticsViewModel.cs @@ -287,34 +287,6 @@ private Services.HealthStatus CalculateOverallHealthFromProperties() #endregion - #region Location Queue Properties - - [ObservableProperty] - private string _queueHealthStatus = "Unknown"; - - [ObservableProperty] - private int _pendingLocations; - - [ObservableProperty] - private int _retryingLocations; - - [ObservableProperty] - private int _syncedLocations; - - [ObservableProperty] - private int _rejectedLocations; - - [ObservableProperty] - private string _oldestPendingAge = "N/A"; - - [ObservableProperty] - private string _lastSyncTime = "Never"; - - [ObservableProperty] - private string _queueDetails = "No queue data"; - - #endregion - #region Tile Cache Properties [ObservableProperty] @@ -353,16 +325,6 @@ private Services.HealthStatus CalculateOverallHealthFromProperties() #endregion - #region Navigation Properties - - [ObservableProperty] - private bool _hasCachedRoute; - - [ObservableProperty] - private string _cachedRouteInfo = "No cached route"; - - #endregion - #region System Properties [ObservableProperty] @@ -429,16 +391,13 @@ private async Task LoadDataAsync() var queueTask = _appDiagnosticService.GetLocationQueueDiagnosticsAsync(); var cacheTask = _appDiagnosticService.GetTileCacheDiagnosticsAsync(); var trackingTask = _appDiagnosticService.GetTrackingDiagnosticsAsync(); - var navTask = _appDiagnosticService.GetNavigationDiagnosticsAsync(); - - await Task.WhenAll(healthTask, queueTask, cacheTask, trackingTask, navTask); + await Task.WhenAll(healthTask, queueTask, cacheTask, trackingTask); // Update UI UpdateHealthStatus(await healthTask); UpdateLocationQueue(await queueTask); UpdateTileCache(await cacheTask); UpdateTracking(await trackingTask); - UpdateNavigation(await navTask); // Load queue details await LoadQueueDetailsAsync(); @@ -621,108 +580,6 @@ await Share.Default.RequestAsync(new ShareFileRequest } } - // NOTE: Queue management actions (export, clear) moved to Settings > Offline Queue. - // Diagnostics is now read-only for queue information. - - /// - /// Refreshes the location queue data. - /// - [RelayCommand] - private async Task RefreshQueueAsync() - { - try - { - var queueDiag = await _appDiagnosticService.GetLocationQueueDiagnosticsAsync(); - UpdateLocationQueue(queueDiag); - await LoadQueueDetailsAsync(); - await _toastService.ShowSuccessAsync("Queue refreshed"); - } - catch (SQLiteException ex) - { - _logger.LogError(ex, "Database error refreshing queue"); - await _toastService.ShowErrorAsync("Database error refreshing queue"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error refreshing queue"); - await _toastService.ShowErrorAsync("Failed to refresh queue"); - } - } - - /// - /// Loads recent queue entries for display. - /// - private async Task LoadQueueDetailsAsync() - { - try - { - var locations = await _locationQueueRepository.GetAllQueuedLocationsAsync(); - - if (locations.Count == 0) - { - QueueDetails = "Queue is empty"; - return; - } - - // Take most recent 50 entries, ordered by timestamp descending - var recentLocations = locations - .OrderByDescending(l => l.Timestamp) - .Take(50) - .ToList(); - - var sb = new StringBuilder(); - sb.AppendLine($"Showing {recentLocations.Count} of {locations.Count} entries (newest first)"); - sb.AppendLine(new string('-', 60)); - - foreach (var loc in recentLocations) - { - var status = loc.SyncStatus switch - { - Core.Enums.SyncStatus.Pending => loc.IsRejected ? "REJECTED" : - loc.SyncAttempts > 0 ? $"RETRY({loc.SyncAttempts})" : "PENDING", - Core.Enums.SyncStatus.Syncing => "SYNCING", - Core.Enums.SyncStatus.Synced => "SYNCED", - _ => "?" - }; - - // Add USER indicator for user-invoked locations (manual check-ins) - var userTag = loc.IsUserInvoked ? " [USER]" : ""; - - var inv = System.Globalization.CultureInfo.InvariantCulture; - sb.AppendLine($"[{loc.Timestamp:HH:mm:ss}] {status}{userTag}"); - sb.AppendLine($" Loc: {loc.Latitude.ToString("F5", inv)}, {loc.Longitude.ToString("F5", inv)}"); - - if (loc.Accuracy.HasValue) - sb.Append($" Acc: {loc.Accuracy.Value.ToString("F0", inv)}m"); - if (loc.Speed.HasValue) - sb.Append($" Spd: {loc.Speed.Value.ToString("F1", inv)}m/s"); - if (loc.Accuracy.HasValue || loc.Speed.HasValue) - sb.AppendLine(); - - // Show check-in notes for user-invoked locations - if (!string.IsNullOrEmpty(loc.CheckInNotes)) - sb.AppendLine($" Notes: {loc.CheckInNotes}"); - - if (!string.IsNullOrEmpty(loc.LastError)) - sb.AppendLine($" Err: {loc.LastError}"); - - sb.AppendLine(); - } - - QueueDetails = sb.ToString(); - } - catch (SQLiteException ex) - { - _logger.LogError(ex, "Database error loading queue details"); - QueueDetails = "Database error loading queue details"; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error loading queue details"); - QueueDetails = $"Error loading queue details: {ex.Message}"; - } - } - #endregion #region Update Methods @@ -755,29 +612,6 @@ private void UpdateHealthStatus(HealthCheckResult result) }; } - private void UpdateLocationQueue(LocationQueueDiagnostics diag) - { - QueueHealthStatus = diag.QueueHealthStatus; - PendingLocations = diag.PendingCount; - RetryingLocations = diag.RetryingCount; - SyncedLocations = diag.SyncedCount; - RejectedLocations = diag.RejectedCount; - - if (diag.OldestPendingTimestamp.HasValue) - { - var age = DateTime.UtcNow - diag.OldestPendingTimestamp.Value; - OldestPendingAge = age.TotalHours >= 1 - ? $"{age.TotalHours:F1} hours" - : $"{age.TotalMinutes:F0} min"; - } - else - { - OldestPendingAge = "N/A"; - } - - LastSyncTime = diag.LastSyncedTimestamp?.ToLocalTime().ToString("g") ?? "Never"; - } - private void UpdateTileCache(TileCacheDiagnostics diag) { CacheHealthStatus = diag.CacheHealthStatus; @@ -805,22 +639,6 @@ private void UpdateTracking(TrackingDiagnostics diag) } } - private void UpdateNavigation(NavigationDiagnostics diag) - { - HasCachedRoute = diag.HasCachedRoute; - - if (diag.HasCachedRoute) - { - CachedRouteInfo = $"{diag.CachedRouteDestination} - {diag.CachedRouteWaypointCount} waypoints, " + - $"{diag.CachedRouteDistance:F0}m, " + - $"age: {diag.CacheAgeSeconds:F0}s, valid: {diag.IsCacheValid}"; - } - else - { - CachedRouteInfo = "No cached route"; - } - } - private void UpdateSystemInfo(SystemInfo info) { Platform = info.Platform; diff --git a/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs index 907599e..e25f99f 100644 --- a/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs +++ b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs @@ -274,7 +274,7 @@ await Share.Default.RequestAsync(new ShareTextRequest } /// - /// Navigates to the member's location using OSRM routing with straight line fallback. + /// Navigates to the member's location using Direct straight-line guidance. /// Calculates route from current location and displays it on the main map. /// [RelayCommand] @@ -311,8 +311,7 @@ await OpenExternalMapsAsync( return; } - // Map selection to OSRM profile - var osrmProfile = navMethod switch + var travelProfile = navMethod switch { NavigationMethod.Walk => "foot", NavigationMethod.Drive => "car", @@ -326,16 +325,15 @@ await OpenExternalMapsAsync( var destLon = SelectedMember.LastLocation.Longitude; var destName = SelectedMember.DisplayText ?? "Member"; - _logger.LogInformation("Calculating {Mode} route to {Member} at {Lat},{Lon}", osrmProfile, destName, destLat, destLon); + _logger.LogInformation("Calculating Direct guidance to member using {Mode}", travelProfile); - // Calculate route using OSRM with straight line fallback var route = await _tripNavigationService.CalculateRouteToCoordinatesAsync( currentLocation.Latitude, currentLocation.Longitude, destLat, destLon, destName, - osrmProfile); + travelProfile); // Close bottom sheet before navigating IsMemberSheetOpen = false; @@ -349,16 +347,6 @@ await OpenExternalMapsAsync( _logger.LogInformation("Started navigation to {Member}: {Distance:F1}km", destName, route.TotalDistanceMeters / 1000); } - catch (HttpRequestException ex) - { - _logger.LogNetworkWarningIfOnline("Network error calculating route: {Message}", ex.Message); - await _toastService.ShowErrorAsync("Failed to calculate route"); - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - _logger.LogError(ex, "Route calculation timed out"); - await _toastService.ShowErrorAsync("Route calculation timed out"); - } catch (Exception ex) { _logger.LogError(ex, "Unexpected error starting navigation"); diff --git a/src/WayfarerMobile/Views/DiagnosticsPage.xaml b/src/WayfarerMobile/Views/DiagnosticsPage.xaml index 45e469c..4578e99 100644 --- a/src/WayfarerMobile/Views/DiagnosticsPage.xaml +++ b/src/WayfarerMobile/Views/DiagnosticsPage.xaml @@ -158,20 +158,6 @@ - - - - - - - - - - - - diff --git a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs index 95472d3..61386d1 100644 --- a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs +++ b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs @@ -121,7 +121,7 @@ public void UnloadTrip() /// public Task CalculateRouteToPlaceAsync(double currentLat, double currentLon, - string destinationPlaceId, bool fetchFromOsrm = true) + string destinationPlaceId) { _activeRoute = _nextRouteToReturn; return Task.FromResult(_activeRoute); diff --git a/tests/WayfarerMobile.Tests/Infrastructure/PreferencesStub.cs b/tests/WayfarerMobile.Tests/Infrastructure/PreferencesStub.cs deleted file mode 100644 index fc3b229..0000000 --- a/tests/WayfarerMobile.Tests/Infrastructure/PreferencesStub.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Concurrent; - -public static class Preferences -{ - private static readonly ConcurrentDictionary Values = new(); - - public static T Get(string key, T defaultValue) => - Values.TryGetValue(key, out var value) && value is T typed ? typed : defaultValue; - - public static void Set(string key, T value) => Values[key] = value; - - public static void Remove(string key) => Values.TryRemove(key, out _); -} diff --git a/tests/WayfarerMobile.Tests/Unit/Helpers/PolylineDecoderTests.cs b/tests/WayfarerMobile.Tests/Unit/Helpers/PolylineDecoderTests.cs index 7f58b6e..4d92eb0 100644 --- a/tests/WayfarerMobile.Tests/Unit/Helpers/PolylineDecoderTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Helpers/PolylineDecoderTests.cs @@ -199,7 +199,7 @@ public void DecodeToTuples_MatchesDecode() [Fact] public void Decode_RealWorldRoute_DecodesCorrectly() { - // Arrange - A short OSRM route segment + // Arrange - A short encoded Segment geometry // This represents a simple route that can be verified manually var encoded = "mz_eFvinjVnIhK"; // Simple route segment diff --git a/tests/WayfarerMobile.Tests/Unit/Services/OsrmRoutingDecommissionMigrationTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/OsrmRoutingDecommissionMigrationTests.cs new file mode 100644 index 0000000..dd7a1c3 --- /dev/null +++ b/tests/WayfarerMobile.Tests/Unit/Services/OsrmRoutingDecommissionMigrationTests.cs @@ -0,0 +1,57 @@ +using WayfarerMobile.Core.Migrations; + +namespace WayfarerMobile.Tests.Unit.Services; + +public sealed class OsrmRoutingDecommissionMigrationTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("malformed")] + public async Task Apply_RemovesOnlyExactLegacyPreference_AndRerunIsSafe(string? legacyValue) + { + var state = new RecordingState(); + state.Preferences["authentication_token"] = "retained"; + state.Preferences["max_live_cache_size_mb"] = "500"; + if (legacyValue is not null) + { + state.Preferences["cached_osrm_route"] = legacyValue; + } + + await OsrmRoutingDecommissionMigration.ApplyAsync(state, CancellationToken.None); + await OsrmRoutingDecommissionMigration.ApplyAsync(state, CancellationToken.None); + + state.Preferences.Should().NotContainKey("cached_osrm_route"); + state.Preferences.Should().Contain("authentication_token", "retained"); + state.Preferences.Should().Contain("max_live_cache_size_mb", "500"); + state.SchemaVersion.Should().Be(9); + state.CompletionWrites.Should().Be(2); + state.RemovedKeys.Should().OnlyContain(key => key == "cached_osrm_route"); + } + + private sealed class RecordingState : ILegacyOsrmPreferenceState + { + public Dictionary Preferences { get; } = []; + public List RemovedKeys { get; } = []; + public int SchemaVersion { get; private set; } = 8; + public int CompletionWrites { get; private set; } + + public Task RemovePreferencesAsync(IReadOnlyCollection keys, CancellationToken cancellationToken) + { + foreach (var key in keys) + { + RemovedKeys.Add(key); + Preferences.Remove(key); + } + + return Task.CompletedTask; + } + + public Task RecordSchemaVersionAsync(int version, CancellationToken cancellationToken) + { + SchemaVersion = version; + CompletionWrites++; + return Task.CompletedTask; + } + } +} diff --git a/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs index d1dbe87..af9161c 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs @@ -1,9 +1,10 @@ -using System.Net; -using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Moq; using WayfarerMobile.Core.Interfaces; +using WayfarerMobile.Core.Models; using WayfarerMobile.Services; +using CoreTripPlace = WayfarerMobile.Core.Models.TripPlace; +using CoreTripSegment = WayfarerMobile.Core.Models.TripSegment; namespace WayfarerMobile.Tests.Unit.Services; @@ -12,41 +13,58 @@ public sealed class TripNavigationRoutingRemovalTests [Fact] public async Task MapTargetNavigation_DoesNotContactPublicProvider_AndUsesDirectGuidance() { - var transport = new RecordingRouteTransport(); - var navigation = new TripNavigationService( - NullLogger.Instance, - new OsrmRoutingService(new HttpClient(transport), NullLogger.Instance), - new RouteCacheService(NullLogger.Instance), - Mock.Of(), - new NavigationRouteBuilder(NullLogger.Instance), - Mock.Of()); + var navigation = CreateNavigation(); var route = await navigation.CalculateRouteToCoordinatesAsync( 37.9838, 23.7275, 37.9715, 23.7267, "Map target"); - transport.RequestCount.Should().Be(0); route.IsDirectRoute.Should().BeTrue(); route.Waypoints.Should().HaveCount(2); } - private sealed class RecordingRouteTransport : HttpMessageHandler + [Fact] + public void TripPlaceNavigation_UsesSavedSegmentGeometryInOrder() { - public int RequestCount { get; private set; } - - protected override Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) + var origin = new CoreTripPlace { Id = Guid.NewGuid(), Name = "Origin", Latitude = 37.98, Longitude = 23.72, SortOrder = 0 }; + var destination = new CoreTripPlace { Id = Guid.NewGuid(), Name = "Destination", Latitude = 38.00, Longitude = 23.74, SortOrder = 1 }; + var trip = new TripDetails { - RequestCount++; - const string body = """ - {"code":"Ok","routes":[{"geometry":"_p~iF~ps|U_ulLnnqC_mqNvxq`@","distance":1200,"duration":900,"legs":[]}]} - """; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }); - } + Id = Guid.NewGuid(), + Name = "Saved route", + Regions = [new TripRegion { Id = Guid.NewGuid(), Name = "Region", Places = [origin, destination] }], + Segments = + [ + new CoreTripSegment + { + Id = Guid.NewGuid(), + OriginId = origin.Id, + DestinationId = destination.Id, + TransportMode = "walking", + Geometry = """{"type":"LineString","coordinates":[[23.72,37.98],[23.73,37.99],[23.74,38.00]]}""" + } + ] + }; + var state = new Mock(); + state.SetupGet(service => service.LoadedTrip).Returns(trip); + var navigation = CreateNavigation(state.Object); + + navigation.LoadTrip(trip).Should().BeTrue(); + var route = navigation.CalculateRouteToPlace(origin.Latitude, origin.Longitude, destination.Id.ToString()); + + route.Should().NotBeNull(); + route!.IsDirectRoute.Should().BeFalse(); + route.Waypoints.Select(point => (point.Latitude, point.Longitude)).Should().ContainInOrder( + (37.98, 23.72), + (37.99, 23.73), + (38.00, 23.74)); } + + private static TripNavigationService CreateNavigation(ITripStateManager? state = null) => + new( + NullLogger.Instance, + Mock.Of(), + new NavigationRouteBuilder(NullLogger.Instance), + state ?? Mock.Of()); } diff --git a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj index 4fee556..8a7171a 100644 --- a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj +++ b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj @@ -75,8 +75,6 @@ - - From ca788015741a538ba717b2416c8780fbaa39b291 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 01:46:57 +0300 Subject: [PATCH 3/7] Make Serilog configuration analyzable --- src/WayfarerMobile/MauiProgram.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/WayfarerMobile/MauiProgram.cs b/src/WayfarerMobile/MauiProgram.cs index 2e9c84a..0bbab76 100644 --- a/src/WayfarerMobile/MauiProgram.cs +++ b/src/WayfarerMobile/MauiProgram.cs @@ -83,11 +83,12 @@ private static void ConfigureSerilog(ILoggingBuilder logging) var logPath = Path.Combine(logDirectory, "wayfarer-app-.log"); // Configure Serilog - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() + var loggerConfiguration = new LoggerConfiguration() + .MinimumLevel.Information(); #if DEBUG - .MinimumLevel.Debug() + loggerConfiguration.MinimumLevel.Debug(); #endif + Log.Logger = loggerConfiguration .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) .MinimumLevel.Override("System", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.Maui.Controls.Element", LogEventLevel.Error) // Suppress Syncfusion binding warnings From 79fd0efe8a579d1b8c3dbcd48378948ea5741e9f Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 02:06:19 +0300 Subject: [PATCH 4/7] WIP: prove navigation correction defects (checkpoint; tests failing) --- .../TripNavigationRoutingRemovalTests.cs | 108 +++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs index af9161c..0b7ee87 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging.Abstractions; using Moq; +using WayfarerMobile.Core.Enums; using WayfarerMobile.Core.Interfaces; using WayfarerMobile.Core.Models; using WayfarerMobile.Services; @@ -10,6 +11,39 @@ namespace WayfarerMobile.Tests.Unit.Services; public sealed class TripNavigationRoutingRemovalTests { + [Fact] + public async Task AdHocDirectRoute_UpdatesProgressAnnouncesAndArrivesWithoutTripGraph() + { + var audio = new Mock(); + var navigation = CreateNavigation(audio: audio.Object); + var publishedStates = new List(); + var announcements = new List(); + var rerouted = false; + navigation.StateChanged += (_, state) => publishedStates.Add(state); + navigation.InstructionAnnounced += (_, instruction) => announcements.Add(instruction); + navigation.Rerouted += (_, _) => rerouted = true; + + var route = await navigation.CalculateRouteToCoordinatesAsync(0, 0, 0.001, 0, "Map target"); + + var progressing = navigation.UpdateLocation(0.0004, 0); + var arrived = navigation.UpdateLocation(0.001, 0); + + route.IsDirectRoute.Should().BeTrue(); + progressing.Status.Should().Be(NavigationStatus.OnRoute); + progressing.DistanceToDestinationMeters.Should().BePositive(); + progressing.DistanceToNextWaypointMeters.Should().BePositive(); + progressing.EstimatedTimeRemaining.Should().BePositive(); + progressing.ProgressPercent.Should().BeGreaterThan(0); + arrived.Status.Should().Be(NavigationStatus.Arrived); + publishedStates.Select(state => state.Status).Should().ContainInOrder( + NavigationStatus.OnRoute, + NavigationStatus.Arrived); + announcements.Should().ContainSingle(); + audio.Verify(service => service.AnnounceStepInstructionAsync( + It.IsAny(), It.Is(distance => distance > 0)), Times.Once); + rerouted.Should().BeFalse(); + } + [Fact] public async Task MapTargetNavigation_DoesNotContactPublicProvider_AndUsesDirectGuidance() { @@ -61,10 +95,80 @@ public void TripPlaceNavigation_UsesSavedSegmentGeometryInOrder() (38.00, 23.74)); } - private static TripNavigationService CreateNavigation(ITripStateManager? state = null) => + [Fact] + public void TripPlaceNavigation_InvalidSavedGeometry_FallsBackToExplicitDirectRoute() + { + var (trip, origin, destination) = CreateTrip("{not json"); + var state = new Mock(); + state.SetupGet(service => service.LoadedTrip).Returns(trip); + var navigation = CreateNavigation(state.Object); + + navigation.LoadTrip(trip).Should().BeTrue(); + var route = navigation.CalculateRouteToPlace( + origin.Latitude, origin.Longitude, destination.Id.ToString()); + + route.Should().NotBeNull(); + route!.IsDirectRoute.Should().BeTrue(); + route.Waypoints.Should().HaveCount(2); + } + + [Fact] + public void TripPlaceNavigation_WithoutSavedPath_ReturnsExplicitDirectRoute() + { + var (trip, origin, destination) = CreateTrip(geometry: null, includeSegment: false); + var state = new Mock(); + state.SetupGet(service => service.LoadedTrip).Returns(trip); + var navigation = CreateNavigation(state.Object); + + navigation.LoadTrip(trip).Should().BeTrue(); + var route = navigation.CalculateRouteToPlace( + origin.Latitude, origin.Longitude, destination.Id.ToString()); + + route.Should().NotBeNull(); + route!.IsDirectRoute.Should().BeTrue(); + } + + private static TripNavigationService CreateNavigation( + ITripStateManager? state = null, + INavigationAudioService? audio = null) => new( NullLogger.Instance, - Mock.Of(), + audio ?? Mock.Of(), new NavigationRouteBuilder(NullLogger.Instance), state ?? Mock.Of()); + + private static (TripDetails Trip, CoreTripPlace Origin, CoreTripPlace Destination) CreateTrip( + string? geometry, + bool includeSegment = true) + { + var origin = new CoreTripPlace + { + Id = Guid.NewGuid(), Name = "Origin", Latitude = 37.98, Longitude = 23.72, SortOrder = 0 + }; + var destination = new CoreTripPlace + { + Id = Guid.NewGuid(), Name = "Destination", Latitude = 38.00, Longitude = 23.74, SortOrder = 1 + }; + var trip = new TripDetails + { + Id = Guid.NewGuid(), + Name = "Fallback route", + Regions = [new TripRegion { Id = Guid.NewGuid(), Name = "Region", Places = [origin, destination] }] + }; + if (includeSegment) + { + trip.Segments = + [ + new CoreTripSegment + { + Id = Guid.NewGuid(), + OriginId = origin.Id, + DestinationId = destination.Id, + Geometry = geometry + } + ]; + } + + return (trip, origin, destination); + } } From bcca28818c3445ca4e3e517844d5f698f7857997 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 02:11:13 +0300 Subject: [PATCH 5/7] Fix direct navigation lifecycle and geometry fallback --- .../Navigation/TripNavigationGraphBuilder.cs | 21 +++++++++---------- .../Services/NavigationRouteBuilder.cs | 3 ++- .../Services/TripNavigationService.cs | 5 +++-- .../TripNavigationGraphBuilderTests.cs | 12 ----------- .../TripNavigationRoutingRemovalTests.cs | 20 ++++++++++++++++++ 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/WayfarerMobile.Core/Navigation/TripNavigationGraphBuilder.cs b/src/WayfarerMobile.Core/Navigation/TripNavigationGraphBuilder.cs index 6d90f32..d2425b6 100644 --- a/src/WayfarerMobile.Core/Navigation/TripNavigationGraphBuilder.cs +++ b/src/WayfarerMobile.Core/Navigation/TripNavigationGraphBuilder.cs @@ -36,26 +36,25 @@ public static TripNavigationGraph Build( foreach (var segment in trip.Segments) { var parseResult = TripSegmentGeometryParser.Parse(segment.Geometry); - if (segment.Waypoints.Count == 0) + if (!parseResult.IsSuccess) { - var edge = CreateEdge(segment, segment.OriginId, segment.DestinationId, - segment.DistanceKm ?? 0, (int)(segment.DurationMinutes ?? 0)); - if (parseResult.IsSuccess) edge.RouteGeometry = ToRoutePoints(parseResult.Coordinates); - else if (parseResult.Failure != SegmentGeometryFailure.Empty) + if (parseResult.Failure != SegmentGeometryFailure.Empty) geometryFailure?.Invoke(segment.Id, parseResult.Failure!.Value); - graph.AddEdge(edge); continue; } - IReadOnlyList? parsedGeometry = parseResult.IsSuccess - ? parseResult.Coordinates.Select(point => new SegmentCoordinate(point.Latitude, point.Longitude)).ToList() - : null; - if (!parseResult.IsSuccess && parseResult.Failure != SegmentGeometryFailure.Empty) + if (segment.Waypoints.Count == 0) { - geometryFailure?.Invoke(segment.Id, parseResult.Failure!.Value); + var edge = CreateEdge(segment, segment.OriginId, segment.DestinationId, + segment.DistanceKm ?? 0, (int)(segment.DurationMinutes ?? 0)); + edge.RouteGeometry = ToRoutePoints(parseResult.Coordinates); + graph.AddEdge(edge); continue; } + IReadOnlyList parsedGeometry = parseResult.Coordinates + .Select(point => new SegmentCoordinate(point.Latitude, point.Longitude)).ToList(); + var resolution = SegmentAnchorResolver.Resolve(segment, trip.AllPlaces, parsedGeometry); if (!resolution.IsValid) continue; diff --git a/src/WayfarerMobile/Services/NavigationRouteBuilder.cs b/src/WayfarerMobile/Services/NavigationRouteBuilder.cs index 8329718..8c3fca4 100644 --- a/src/WayfarerMobile/Services/NavigationRouteBuilder.cs +++ b/src/WayfarerMobile/Services/NavigationRouteBuilder.cs @@ -99,7 +99,8 @@ public NavigationRoute BuildDirectRoute( }, DestinationName = destination.Name, TotalDistanceMeters = distance, - EstimatedDuration = TimeSpan.FromSeconds(distance / 1.4) + EstimatedDuration = TimeSpan.FromSeconds(distance / 1.4), + IsDirectRoute = true }; } diff --git a/src/WayfarerMobile/Services/TripNavigationService.cs b/src/WayfarerMobile/Services/TripNavigationService.cs index 44fd3cf..585a3f3 100644 --- a/src/WayfarerMobile/Services/TripNavigationService.cs +++ b/src/WayfarerMobile/Services/TripNavigationService.cs @@ -250,7 +250,7 @@ public TripNavigationState UpdateLocation(double currentLat, double currentLon) { var state = new TripNavigationState(); - if (_activeRoute == null || _currentGraph == null) + if (_activeRoute == null) { state.Status = NavigationStatus.NoRoute; return state; @@ -280,7 +280,8 @@ public TripNavigationState UpdateLocation(double currentLat, double currentLon) // Check if off-route var currentEdge = GetCurrentEdge(currentLat, currentLon); - if (currentEdge != null && _currentGraph.IsOffRoute(currentLat, currentLon, currentEdge)) + if (_currentGraph != null && currentEdge != null && + _currentGraph.IsOffRoute(currentLat, currentLon, currentEdge)) { state.Status = NavigationStatus.OffRoute; state.Message = "You are off route. Recalculating..."; diff --git a/tests/WayfarerMobile.Tests/Unit/Navigation/TripNavigationGraphBuilderTests.cs b/tests/WayfarerMobile.Tests/Unit/Navigation/TripNavigationGraphBuilderTests.cs index 7ceee26..86de705 100644 --- a/tests/WayfarerMobile.Tests/Unit/Navigation/TripNavigationGraphBuilderTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Navigation/TripNavigationGraphBuilderTests.cs @@ -27,18 +27,6 @@ public void Build_ApiGeoJsonSegment_RetainsEdgeWithExactRouteGeometry() edge.RouteGeometry.Should().NotBeEquivalentTo(corruptPoints); } - [Fact] - public void Build_InvalidSegmentGeometry_RetainsEdgeWithoutDetailedGeometry() - { - var (trip, fromId, toId) = CreateTrip("{not json"); - - var graph = TripNavigationGraphBuilder.Build(trip); - - var edge = graph.GetEdgeBetween(fromId.ToString(), toId.ToString()); - edge.Should().NotBeNull(); - edge!.RouteGeometry.Should().BeNullOrEmpty(); - } - private static (TripDetails Trip, Guid FromId, Guid ToId) CreateTrip(string geometry) { var fromId = Guid.NewGuid(); diff --git a/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs index 0b7ee87..e0661f8 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs @@ -112,6 +112,26 @@ public void TripPlaceNavigation_InvalidSavedGeometry_FallsBackToExplicitDirectRo route.Waypoints.Should().HaveCount(2); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TripPlaceNavigation_UnavailableSavedGeometry_FallsBackToExplicitDirectRoute(string? geometry) + { + var (trip, origin, destination) = CreateTrip(geometry); + var state = new Mock(); + state.SetupGet(service => service.LoadedTrip).Returns(trip); + var navigation = CreateNavigation(state.Object); + + navigation.LoadTrip(trip).Should().BeTrue(); + var route = navigation.CalculateRouteToPlace( + origin.Latitude, origin.Longitude, destination.Id.ToString()); + + route.Should().NotBeNull(); + route!.IsDirectRoute.Should().BeTrue(); + route.Waypoints.Should().HaveCount(2); + } + [Fact] public void TripPlaceNavigation_WithoutSavedPath_ReturnsExplicitDirectRoute() { From e61c145651b10191c8b80fdfe078aef7d257e814 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 11:58:48 +0300 Subject: [PATCH 6/7] WIP: prove stale navigation session state (checkpoint; tests failing) --- .../TripNavigationRoutingRemovalTests.cs | 57 +++++++++++++++++++ .../NavigationCoordinatorViewModelTests.cs | 14 ----- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs index e0661f8..02b34be 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs @@ -44,6 +44,63 @@ public async Task AdHocDirectRoute_UpdatesProgressAnnouncesAndArrivesWithoutTrip rerouted.Should().BeFalse(); } + [Fact] + public async Task ReplacingRoute_StartsFreshAnnouncementSession() + { + var navigation = CreateNavigation(); + var announcements = new List(); + navigation.InstructionAnnounced += (_, instruction) => announcements.Add(instruction); + + await navigation.CalculateRouteToCoordinatesAsync(0, 0, 0.001, 0, "Route A"); + navigation.UpdateLocation(0.0002, 0); + await navigation.CalculateRouteToCoordinatesAsync(0, 0, 0, 0.001, "Route B"); + + navigation.UpdateLocation(0, 0.0002); + + announcements.Should().HaveCount(2); + } + + [Fact] + public async Task StopNavigation_ClearsRouteAndPreventsFurtherUpdates() + { + var navigation = CreateNavigation(); + var publishedStates = new List(); + var announcements = new List(); + navigation.StateChanged += (_, state) => publishedStates.Add(state); + navigation.InstructionAnnounced += (_, instruction) => announcements.Add(instruction); + await navigation.CalculateRouteToCoordinatesAsync(0, 0, 0.001, 0, "Destination"); + navigation.UpdateLocation(0.0002, 0); + var stateCountAtStop = publishedStates.Count; + var announcementCountAtStop = announcements.Count; + + navigation.StopNavigation(); + var afterStop = navigation.UpdateLocation(0.0003, 0); + + navigation.ActiveRoute.Should().BeNull(); + afterStop.Status.Should().Be(NavigationStatus.NoRoute); + publishedStates.Should().HaveCount(stateCountAtStop); + announcements.Should().HaveCount(announcementCountAtStop); + } + + [Fact] + public async Task Arrival_PublishesCompletionThenClearsRoute() + { + var navigation = CreateNavigation(); + var publishedStates = new List(); + navigation.StateChanged += (_, state) => publishedStates.Add(state); + await navigation.CalculateRouteToCoordinatesAsync(0, 0, 0.001, 0, "Destination"); + + var arrived = navigation.UpdateLocation(0.001, 0); + var stateCountAtArrival = publishedStates.Count; + var afterArrival = navigation.UpdateLocation(0.0009, 0); + + arrived.Status.Should().Be(NavigationStatus.Arrived); + publishedStates.Should().ContainSingle(state => state.Status == NavigationStatus.Arrived); + navigation.ActiveRoute.Should().BeNull(); + afterArrival.Status.Should().Be(NavigationStatus.NoRoute); + publishedStates.Should().HaveCount(stateCountAtArrival); + } + [Fact] public async Task MapTargetNavigation_DoesNotContactPublicProvider_AndUsesDirectGuidance() { diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorViewModelTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorViewModelTests.cs index 1648243..d5d62a5 100644 --- a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorViewModelTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorViewModelTests.cs @@ -206,13 +206,6 @@ public void StopNavigation_NotifiesVisitService() // _visitNotificationService.UpdateNavigationState(false, null); } - [Fact] - public void StopNavigation_ClearsNavigationRoute() - { - // Document expected behavior: - // _callbacks?.ClearNavigationRoute(); - } - [Fact] public void StopNavigation_WithSelectedPlace_CentersOnPlace() { @@ -252,13 +245,6 @@ public void UpdateLocation_WhenNavigating_UpdatesRouteProgress() // _callbacks?.UpdateNavigationRouteProgress(route, lat, lon); } - [Fact] - public void UpdateLocation_WhenArrived_StopsNavigation() - { - // Document expected behavior: - // if (state.Status == NavigationStatus.Arrived) StopNavigation(); - } - #endregion #region StartNavigationWithRouteAsync Tests From 5ab321cc3c08cbe6ea049ad04557a2a7ba51c619 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 12:01:26 +0300 Subject: [PATCH 7/7] Fix navigation session lifecycle reset --- .../Interfaces/ITripNavigationService.cs | 5 +++ .../Services/TripNavigationService.cs | 39 +++++++++++-------- .../NavigationCoordinatorViewModel.cs | 2 + .../Mocks/MockTripNavigationService.cs | 6 +++ 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs b/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs index a9244e2..0c212d6 100644 --- a/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs +++ b/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs @@ -43,6 +43,11 @@ public interface ITripNavigationService /// NavigationRoute? ActiveRoute { get; } + /// + /// Stops the active navigation session without unloading trip data. + /// + void StopNavigation(); + /// /// Loads a trip for navigation, building the routing graph. /// diff --git a/src/WayfarerMobile/Services/TripNavigationService.cs b/src/WayfarerMobile/Services/TripNavigationService.cs index 585a3f3..fd4c465 100644 --- a/src/WayfarerMobile/Services/TripNavigationService.cs +++ b/src/WayfarerMobile/Services/TripNavigationService.cs @@ -122,11 +122,18 @@ public bool LoadTrip(TripDetails trip) /// public void UnloadTrip() { + StopNavigation(); _currentGraph = null; _currentTrip = null; + } + + /// + public void StopNavigation() + { _activeRoute = null; _destinationPlaceId = null; _announcedStepKeys.Clear(); + _lastAnnouncementTime = DateTime.MinValue; } /// @@ -153,9 +160,6 @@ public void UnloadTrip() return null; } - _destinationPlaceId = destinationPlaceId; - _announcedStepKeys.Clear(); // Reset announcements for new route - // Priority 1: Check for user-defined segment route if (_currentGraph.IsWithinSegmentRoutingRange(currentLat, currentLon)) { @@ -165,17 +169,19 @@ public void UnloadTrip() var path = _currentGraph.FindPath(nearestNode.Id, destinationPlaceId); if (path.Count > 0) { - _activeRoute = _routeBuilder.BuildFromSegmentPath(path, currentLat, currentLon, _currentGraph); - _logger.LogDebug("Using segment route with {WaypointCount} waypoints", _activeRoute?.Waypoints.Count); - return _activeRoute; + var segmentRoute = _routeBuilder.BuildFromSegmentPath(path, currentLat, currentLon, _currentGraph); + InstallRoute(segmentRoute, destinationPlaceId); + _logger.LogDebug("Using segment route with {WaypointCount} waypoints", segmentRoute.Waypoints.Count); + return segmentRoute; } } } // Priority 2: Direct navigation (bearing + distance) - _activeRoute = _routeBuilder.BuildDirectRoute(currentLat, currentLon, destination); + var directRoute = _routeBuilder.BuildDirectRoute(currentLat, currentLon, destination); + InstallRoute(directRoute, destinationPlaceId); _logger.LogDebug("Using direct route to {Destination}", destination.Name); - return _activeRoute; + return directRoute; } /// @@ -213,12 +219,9 @@ public Task CalculateRouteToCoordinatesAsync( string profile = "foot") { _logger.LogInformation("Calculating Direct guidance to {Name}", destName); - _announcedStepKeys.Clear(); // Reset announcements for new route - - // Direct route (straight line) with profile-aware ETA _logger.LogInformation("Using direct route to {Name} with profile {Profile}", destName, profile); var directRoute = _routeBuilder.BuildDirectRouteToCoordinates(currentLat, currentLon, destLat, destLon, destName, profile); - _activeRoute = directRoute; + InstallRoute(directRoute, destinationPlaceId: null); return Task.FromResult(directRoute); } @@ -256,7 +259,6 @@ public TripNavigationState UpdateLocation(double currentLat, double currentLon) return state; } - // Calculate distance to destination var destinationWaypoint = _activeRoute.Waypoints.LastOrDefault(); if (destinationWaypoint != null) { @@ -269,12 +271,12 @@ public TripNavigationState UpdateLocation(double currentLat, double currentLon) destinationWaypoint.Latitude, destinationWaypoint.Longitude); } - // Check for arrival if (state.DistanceToDestinationMeters <= TripNavigationGraph.SegmentRoutingThresholdMeters) { state.Status = NavigationStatus.Arrived; state.Message = $"You have arrived at {_activeRoute.DestinationName}"; StateChanged?.Invoke(this, state); + StopNavigation(); return state; } @@ -302,7 +304,6 @@ public TripNavigationState UpdateLocation(double currentLat, double currentLon) return state; } - // On route - calculate progress state.Status = NavigationStatus.OnRoute; state.DistanceToNextWaypointMeters = CalculateDistanceToNextWaypoint(currentLat, currentLon); state.NextWaypointName = GetNextWaypointName(currentLat, currentLon); @@ -312,13 +313,19 @@ public TripNavigationState UpdateLocation(double currentLat, double currentLon) // Estimate time remaining (walking speed 5 km/h) state.EstimatedTimeRemaining = TimeSpan.FromSeconds(state.DistanceToDestinationMeters / 1.4); - // Check for turn announcements CheckForTurnAnnouncement(currentLat, currentLon, state); StateChanged?.Invoke(this, state); return state; } + private void InstallRoute(NavigationRoute route, string? destinationPlaceId) + { + StopNavigation(); + _activeRoute = route; + _destinationPlaceId = destinationPlaceId; + } + /// /// Checks if a turn announcement should be made and announces it. /// diff --git a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs index 594d782..3025a81 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -182,6 +182,8 @@ public async Task StartNavigationToNextAsync() [RelayCommand] public void StopNavigation() { + _tripNavigationService.StopNavigation(); + // Notify visit notification service that navigation ended _currentNavigationPlaceId = null; _visitNotificationService.UpdateNavigationState(false, null); diff --git a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs index 61386d1..4c76cab 100644 --- a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs +++ b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs @@ -33,6 +33,12 @@ public class MockTripNavigationService : ITripNavigationService /// public NavigationRoute? ActiveRoute => _activeRoute; + /// + public void StopNavigation() + { + _activeRoute = null; + } + /// /// Gets the loaded trip, if any. ///