diff --git a/CHANGELOG.md b/CHANGELOG.md index 47817cc..230bb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## 1.2.0 +### 2026-08-30 +- **Feature: transient authenticated Wayfarer routing (#260)** + - Discovers and confirms server-owned routing profiles before requesting provider-neutral routes + - Keeps provider credentials server-side and never contacts a routing provider directly + - Preserves saved Segment geometry priority with Direct guidance for unavailable, rejected, cancelled, or stale work + - Retains safe server-returned attribution and hosted provenance only with the active route + - Keeps hosted routes and profile selections session-only; offline retention remains future work in #261 + ### 2026-06-20 - **Feature: Search private trips (#228, PR #231)** - Added local, case-insensitive trip-name search to the My Trips tab diff --git a/docs/03-Features.md b/docs/03-Features.md index e639aca..1bbc8a2 100644 --- a/docs/03-Features.md +++ b/docs/03-Features.md @@ -301,7 +301,7 @@ From the main map, you can add your current location to the loaded trip as a new ## Navigation -Navigate with saved Trip Segment geometry or honest straight-line Direct guidance. Mobile does not contact a public routing provider. +Navigate with saved Trip Segment geometry, transient authenticated Wayfarer-hosted routing, or honest straight-line Direct guidance. Mobile contacts only the configured Wayfarer server; that server may use its selected 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 | Direct guidance to member location | -| **Map Navigation** | Long-press on map | Direct guidance to any point | +| **Group Navigation** | Groups → member | Hosted routing when available, otherwise Direct | +| **Map Navigation** | Long-press on map | Hosted routing when available, otherwise Direct | ### Starting Trip Navigation @@ -343,12 +343,14 @@ Route calculation differs based on navigation context: | Priority | Source | When Used | |----------|--------|-----------| | 1 | **User Segments** | Trip has pre-defined route geometry | -| 2 | **Direct Route** | Saved geometry is unavailable or invalid | +| 2 | **Wayfarer hosted route** | Saved geometry is unavailable and authenticated routing is available | +| 3 | **Direct Route** | Hosted routing is not selected, unavailable, or rejected | **Ad-Hoc Navigation** (groups, map locations): | Priority | Source | When Used | |----------|--------|-----------| -| 1 | **Direct Route** | Always; ad-hoc targets have no saved Segment geometry | +| 1 | **Wayfarer hosted route** | Authenticated routing is available and selected | +| 2 | **Direct Route** | Hosted routing is not selected, unavailable, or rejected | > **Note**: Ad-hoc navigation does not have saved Segment geometry because there is no Trip context. @@ -359,7 +361,8 @@ Route calculation differs based on navigation context: - 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. +Direct is not road-aware. Hosted route geometry, attribution, and safe provider/profile provenance remain only with +the active route and are not retained for offline use; replacement or stop clears them. #261 owns offline retention. ### External Maps Integration diff --git a/docs/07-Troubleshooting.md b/docs/07-Troubleshooting.md index cc5a62d..d668165 100644 --- a/docs/07-Troubleshooting.md +++ b/docs/07-Troubleshooting.md @@ -257,6 +257,14 @@ For detailed troubleshooting: 3. **Check destination**: Place must have valid coordinates 4. **Try different place**: Some places may have issues +### Hosted Routing Falls Back to Direct + +Direct remains usable when the configured Wayfarer server is old, routing is disabled, no provider is available, +authentication authority changes, or live location/target/profile state no longer matches a delayed response. Confirm +the server supports the Mobile routing endpoints and that routing is enabled for your account. Provider credentials +are configured only on the server and are never entered in Mobile. A hosted route and its safe provenance are +session-only; offline retention is deferred to #261. + ### Off-Route Constantly **Symptoms:** diff --git a/docs/11-Architecture.md b/docs/11-Architecture.md index 5b5e6e7..4681858 100644 --- a/docs/11-Architecture.md +++ b/docs/11-Architecture.md @@ -428,9 +428,15 @@ services.AddHttpClient("WayfarerApi", client => The `TripNavigationService` calculates routes with the following priority: 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. +2. **Authenticated Wayfarer route**: Fresh provider-neutral, session-only geometry +3. **Direct guidance**: Straight line with bearing and distance + +Mobile contacts only its configured Wayfarer server. Routing identity uses a non-secret, process-local authentication +session revision rather than the bearer token. In the final synchronous UI callback, the coordinator rebuilds current +authority from the settings owner, live device location, current Trip Place/Segment data or member owner, and the +hosted selection owner. It compares generation, normalized server, target and Segment identity, profile/authority, +choice, and canonical origin/anchors/destination immediately before installation, with no await or dispatch gap. +Provider credentials and provider-specific endpoints remain server-side. ### Navigation Graph diff --git a/docs/12-Services.md b/docs/12-Services.md index 3d91c5a..8c1be97 100644 --- a/docs/12-Services.md +++ b/docs/12-Services.md @@ -488,7 +488,8 @@ 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. Mobile makes no direct routing-provider request. +Provides navigation installation and progress tracking. A separate hosted-routing owner uses the authenticated +Wayfarer server; Mobile never contacts a routing provider directly. ### Navigation Modes @@ -497,13 +498,15 @@ Provides navigation with route calculation and progress tracking. Mobile makes n - Has access to user-defined segments and trip context - Route priority: 1. Valid saved Segment geometry (trip-defined routes) - 2. Direct Route (straight-line fallback) + 2. A freshly requested, transient Wayfarer-hosted route + 3. Direct Route (straight-line fallback) **Ad-Hoc Navigation** (`CalculateRouteToCoordinatesAsync`): - Used for groups, map locations, any coordinates - No trip context available - Route priority: - 1. Direct Route + 1. A freshly requested, transient Wayfarer-hosted route + 2. Direct Route ```csharp // Trip navigation - uses full route priority chain @@ -548,7 +551,28 @@ public NavigationRoute? CalculateRouteToPlace( } ``` -Direct guidance is not road-aware or hosted turn-by-turn routing. Authenticated Wayfarer-hosted routing remains future work. +Hosted routes are authenticated, provider-neutral, session-only results. Provider credentials and provider selection +remain on Wayfarer. The active route retains linked attribution plus safe transient provenance: selected transport +profile and authority identities, provider and provider-configuration identities, mapping identity, storage mode, and +the normalized backend generation timestamp. It contains no bearer token, credentials, or provider endpoint and +clears through normal replacement or stop. Old servers, disabled routing, rejected requests, cancellation, +malformed/stale responses, and provider +unavailability remain routing-local and retain Direct guidance without affecting authentication or synchronization. +Valid saved Segment geometry is never replaced automatically. Mobile does not persist generated geometry, selection, +attribution, or authority identities; offline retention of hosted routes belongs to #261. + +Chooser entries are scoped to the exact discovery catalog displayed. Mobile submits that catalog identity with the +chosen profile; a `catalog-changed` capability response makes no route request, rediscovers once, and requires a +fresh choice from the refreshed labels or retains Direct when dismissed. Once capability succeeds, unrelated later +catalog changes do not invalidate the confirmed route. + +`TransportProfileId` is the Segment's current planning profile identity. Current hosted selection state remains +separate from the immutable provenance retained on a successfully published route; neither rewrites the Segment nor +becomes a durable current-profile setting. The settings owner advances a non-secret, memory-only authentication +session revision whenever the effective server or token authority changes, including logout/reset, so routing never +copies or compares the token. At actual publication the coordinator rereads live location, exact Trip Place/Segment +profile/ordered anchors by stable IDs, or the current member location from its owner. That state is compared beside +the synchronous route/provenance copy. ### Navigation State diff --git a/docs/13-API.md b/docs/13-API.md index cbf7ffa..c059d5c 100644 --- a/docs/13-API.md +++ b/docs/13-API.md @@ -477,7 +477,25 @@ public async Task> SendAsync(HttpRequestMessage request) ## Mobile Routing Boundary -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. +Mobile never contacts a public or commercial routing provider. It discovers eligible profiles with authenticated +`GET /api/mobile/routing/profiles`, confirms a selected profile with +`GET /api/mobile/routing/capability/{transportProfileId}`, and requests a transient route with +`POST /api/mobile/routing/route`. The discovery catalog identity scopes only pre-capability selection; the selected +profile authority identity fences route execution and publication. Bearer credentials remain bound to the configured +Wayfarer server, provider credentials stay server-side, and returned attribution is displayed as supplied. Mobile +uses only a non-secret process-local authentication revision plus the normalized server for in-flight publication +identity; it never copies or hashes the bearer token into routing state. + +A chooser selection carries the discovery identity of the catalog the user actually saw into capability. A +`catalog-changed` response causes one bounded rediscovery and refreshed presentation; cancellation retains Direct and +no route request is sent. Catalog drift after successful capability is outside chooser authority and does not by +itself invalidate the confirmed selected profile. + +Valid downloaded Trip Segment geometry remains higher authority. Hosted failures, old-server 404 responses, disabled +providers, cancellation, and stale results fall back to Direct straight-line guidance without changing the general +session. Hosted route output and profile choices are never persisted; offline hosted-route retention belongs to #261. +Safe provider/profile provenance remains attached only to a successfully published active route and clears on normal +replacement or stop. ## JSON Serialization diff --git a/docs/15-Security.md b/docs/15-Security.md index afdfc70..8598a18 100644 --- a/docs/15-Security.md +++ b/docs/15-Security.md @@ -98,6 +98,17 @@ The QR code for app configuration contains only: - Tokens are not included in crash reports - Tokens are cleared on logout +### Hosted Routing Disclosure + +When a user requests hosted routing, Mobile sends the selected profile identity and the route's origin, destination, +and approved ordered anchors to the configured Wayfarer backend. Wayfarer may disclose those coordinates to its +selected routing provider. Provider credentials, provider endpoints, and native provider modes remain server-side. +Mobile uses a non-secret, process-local authentication revision to invalidate in-flight work when the effective +server/token authority changes; the bearer token is never copied, hashed, logged, or persisted as routing identity. +At publication it rereads the live origin and the exact current Trip or member target owner. The active hosted route +retains only safe provider/profile provenance and linked attribution, which clear on replacement or stop. #261 owns +any future offline retention policy. + ## Secure Storage ### MAUI SecureStorage diff --git a/src/WayfarerMobile.Core/Interfaces/IDialogService.cs b/src/WayfarerMobile.Core/Interfaces/IDialogService.cs index 015254a..7a8048a 100644 --- a/src/WayfarerMobile.Core/Interfaces/IDialogService.cs +++ b/src/WayfarerMobile.Core/Interfaces/IDialogService.cs @@ -43,4 +43,7 @@ public interface IDialogService /// The error message. /// Optional retry action. Task ShowErrorWithRetryAsync(string title, string message, Func? retryAction = null); + + /// Shows a focused transient choice and returns null when dismissed. + Task SelectAsync(string title, IReadOnlyList choices, string cancel = "Cancel"); } diff --git a/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs b/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs index f8b9d1e..2dc14c6 100644 --- a/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs +++ b/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs @@ -32,6 +32,11 @@ public interface ISettingsService /// string? ApiToken { get; set; } + /// + /// Gets the non-secret in-memory revision of the effective authentication authority. + /// + long AuthenticationSessionRevision { get; } + /// /// Gets or sets the minimum time between logged locations (from server). /// diff --git a/src/WayfarerMobile.Core/Models/NavigationRoute.cs b/src/WayfarerMobile.Core/Models/NavigationRoute.cs index 8c01f45..0cd584a 100644 --- a/src/WayfarerMobile.Core/Models/NavigationRoute.cs +++ b/src/WayfarerMobile.Core/Models/NavigationRoute.cs @@ -39,8 +39,27 @@ public class NavigationRoute /// Gets or sets the initial bearing for direct routes (degrees from north). /// public double InitialBearing { get; set; } + + /// Gets transient linked attribution for the active hosted route. + public List Attribution { get; set; } = new(); + + /// Gets or sets safe transient provenance for the active hosted route. + public HostedRouteProvenance? HostedProvenance { get; set; } } +/// Contains one safe linked attribution displayed only with an active hosted route. +public sealed record HostedRouteAttribution(string Text, string Url); + +/// Safe memory-only provenance retained with an active hosted route. +public sealed record HostedRouteProvenance( + Guid TransportProfileId, + string SelectedProfileAuthorityIdentity, + string Provider, + Guid ProviderConfigurationId, + string MappingIdentity, + string StorageMode, + DateTimeOffset GeneratedAt); + /// /// A single turn-by-turn instruction in the navigation route. /// diff --git a/src/WayfarerMobile/MauiProgram.cs b/src/WayfarerMobile/MauiProgram.cs index 0bbab76..7aa4903 100644 --- a/src/WayfarerMobile/MauiProgram.cs +++ b/src/WayfarerMobile/MauiProgram.cs @@ -192,6 +192,8 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // Drains offline queue via check-in endpoint services.AddSingleton(); services.AddSingleton(); diff --git a/src/WayfarerMobile/Services/ApiClient.cs b/src/WayfarerMobile/Services/ApiClient.cs index 2d9fa64..575237d 100644 --- a/src/WayfarerMobile/Services/ApiClient.cs +++ b/src/WayfarerMobile/Services/ApiClient.cs @@ -4,6 +4,7 @@ using System.Net.Http.Json; using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.Logging; using Polly; using Polly.Retry; @@ -32,15 +33,22 @@ public class ApiClient : IApiClient, IVisitApiClient /// private readonly CircuitBreakerState _circuitBreaker = new(threshold: 3, cooldown: TimeSpan.FromSeconds(30)); - private static readonly JsonSerializerOptions JsonOptions = new() + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + private static JsonSerializerOptions CreateJsonOptions() { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - // Use relaxed encoding to prevent HTML characters (<, >) from being escaped to \u003C, \u003E - // This is needed for notes HTML content to be stored correctly on the server - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - Converters = { new UtcDateTimeConverter() } - }; + var resolver = new DefaultJsonTypeInfoResolver(); + resolver.Modifiers.Add(HostedSegmentProfileIdentity.Configure); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + TypeInfoResolver = resolver, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + options.Converters.Add(new UtcDateTimeConverter()); + return options; + } /// /// HTTP status codes that are considered transient and should be retried. diff --git a/src/WayfarerMobile/Services/DialogService.cs b/src/WayfarerMobile/Services/DialogService.cs index 711d3ab..9ce86b0 100644 --- a/src/WayfarerMobile/Services/DialogService.cs +++ b/src/WayfarerMobile/Services/DialogService.cs @@ -78,6 +78,13 @@ public async Task ShowErrorWithRetryAsync(string title, string message, Func + public async Task SelectAsync(string title, IReadOnlyList choices, string cancel = "Cancel") + { + var page = GetCurrentPage(); + return page == null ? null : await page.DisplayActionSheetAsync(title, cancel, null, choices.ToArray()); + } + private static Page? GetCurrentPage() { if (Application.Current?.Windows.Count > 0) diff --git a/src/WayfarerMobile/Services/HostedRoutingApiClient.cs b/src/WayfarerMobile/Services/HostedRoutingApiClient.cs new file mode 100644 index 0000000..8c4ec8b --- /dev/null +++ b/src/WayfarerMobile/Services/HostedRoutingApiClient.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Globalization; +using WayfarerMobile.Core.Interfaces; +using WayfarerMobile.Core.Models; + +namespace WayfarerMobile.Services; + +/// Calls only the authenticated Wayfarer Mobile routing contract with bounded strict parsing. +public sealed class HostedRoutingApiClient : IHostedRoutingApiClient +{ + private const int MaximumResponseBytes = 2 * 1024 * 1024; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly IHttpClientFactory httpClientFactory; + private readonly ISettingsService settings; + + public HostedRoutingApiClient(IHttpClientFactory httpClientFactory, ISettingsService settings) + { + this.httpClientFactory = httpClientFactory; + this.settings = settings; + } + + public async Task DiscoverAsync(CancellationToken cancellationToken) + { + using var response = await SendAsync(HttpMethod.Get, "/api/mobile/routing/profiles", null, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) return new(null, "unavailable", []); + if (!response.IsSuccessStatusCode) return new(null, "unavailable", []); + return await ParseAsync(response, cancellationToken) + ?? new(null, "invalid-response", []); + } + + public async Task GetCapabilityAsync(Guid profileId, + string discoveryCatalogIdentity, CancellationToken cancellationToken) + { + var endpoint = $"/api/mobile/routing/capability/{profileId:D}?discoveryCatalogIdentity={Uri.EscapeDataString(discoveryCatalogIdentity)}"; + using var response = await SendAsync(HttpMethod.Get, endpoint, null, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + return new("unavailable", profileId, null, null, null, null, null, null, null); + var value = await ParseAsync(response, cancellationToken); + return value?.ToModel() ?? new("invalid-response", profileId, null, null, null, null, null, null, null); + } + + public async Task GetRouteAsync(HostedRouteRequest request, + CancellationToken cancellationToken) + { + using var response = await SendAsync(HttpMethod.Post, "/api/mobile/routing/route", request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) return Failure("unavailable"); + var value = await ParseAsync(response, cancellationToken); + return value?.ToModel() ?? Failure("invalid-response"); + } + + private async Task SendAsync(HttpMethod method, string endpoint, object? body, + CancellationToken cancellationToken) + { + if (!settings.IsConfigured || !Uri.TryCreate(settings.ServerUrl, UriKind.Absolute, out var server) + || server.Scheme is not ("https" or "http")) throw new HttpRequestException("Wayfarer is unavailable."); + using var request = new HttpRequestMessage(method, $"{settings.ServerUrl!.TrimEnd('/')}{endpoint}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiToken); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + if (body != null) request.Content = JsonContent.Create(body, options: JsonOptions); + return await httpClientFactory.CreateClient("WayfarerApi").SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + } + + private static async Task ParseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentLength > MaximumResponseBytes) return default; + await using var source = await response.Content.ReadAsStreamAsync(cancellationToken); + await using var bounded = new MemoryStream(); + var buffer = new byte[8192]; + while (true) + { + var read = await source.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + if (bounded.Length + read > MaximumResponseBytes) return default; + await bounded.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + bounded.Position = 0; + try { return await JsonSerializer.DeserializeAsync(bounded, JsonOptions, cancellationToken); } + catch (JsonException) { return default; } + } + + private static HostedRouteResponse Failure(string outcome) => + new(false, outcome, null, null, null, null, null, null, null, null, null, null, null, null, null); + + private sealed record CapabilityDto(string Outcome, Guid TransportProfileId, string? Provider, + Guid? ProviderConfigurationId, string? MappingIdentity, string? StorageMode, + IReadOnlyList? Attribution, string? DiscoveryCatalogIdentity, + string? SelectedProfileAuthorityIdentity) + { + public HostedRoutingCapability ToModel() => new(Outcome, TransportProfileId, Provider, + ProviderConfigurationId, MappingIdentity, StorageMode, Attribution, DiscoveryCatalogIdentity, + SelectedProfileAuthorityIdentity); + } + + private sealed record RouteResponseDto(bool Succeeded, string Outcome, IReadOnlyList? Geometry, + double? DistanceMetres, double? DurationSeconds, IReadOnlyList? Instructions, + string? GeneratedAt, string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity, + Guid? TransportProfileId, IReadOnlyList? MatchPoints, + IReadOnlyList? Attribution, string? StorageMode, + string? SelectedProfileAuthorityIdentity) + { + public HostedRouteResponse ToModel() => new(Succeeded, Outcome, Geometry, DistanceMetres, DurationSeconds, + Instructions, ParseGeneratedAt(GeneratedAt), Provider, ProviderConfigurationId, MappingIdentity, + TransportProfileId, MatchPoints, Attribution, StorageMode, + SelectedProfileAuthorityIdentity); + + private static DateTimeOffset? ParseGeneratedAt(string? value) + { + if (value == null || !(value.EndsWith('Z') || value.Length >= 6 + && (value[^6] is '+' or '-') && value[^3] == ':')) return null; + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, + out var parsed) ? parsed.ToUniversalTime() : null; + } + } +} diff --git a/src/WayfarerMobile/Services/HostedRoutingModels.cs b/src/WayfarerMobile/Services/HostedRoutingModels.cs new file mode 100644 index 0000000..b5c0535 --- /dev/null +++ b/src/WayfarerMobile/Services/HostedRoutingModels.cs @@ -0,0 +1,252 @@ +using WayfarerMobile.Core.Algorithms; +using WayfarerMobile.Core.Models; + +namespace WayfarerMobile.Services; + +public sealed record HostedRoutingProfile(Guid TransportProfileId, string DisplayName, string ModeKey, string Category); +public sealed record HostedRoutingCatalog(string? DiscoveryCatalogIdentity, string Outcome, IReadOnlyList Profiles); +public sealed record HostedRouteCoordinate(double Longitude, double Latitude); +public sealed record HostedRouteInstruction(string Text, string Type, int FromIndex, int ToIndex, + double DistanceMetres, double DurationSeconds); + +public sealed record HostedRoutingCapability(string Outcome, Guid TransportProfileId, + string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity, string? StorageMode, + IReadOnlyList? Attribution, string? DiscoveryCatalogIdentity, + string? SelectedProfileAuthorityIdentity) +{ + public static HostedRoutingCapability Available(Guid profileId, string catalogIdentity, + string selectedAuthorityIdentity, IReadOnlyList attribution, + string provider = "geoapify", Guid? providerConfigurationId = null, + string mappingIdentity = "mapping", string storageMode = "persistent") => + new("available", profileId, provider, providerConfigurationId ?? Guid.Parse("22222222-2222-2222-2222-222222222222"), + mappingIdentity, storageMode, attribution, catalogIdentity, selectedAuthorityIdentity); +} + +public sealed record HostedRouteRequest(Guid TransportProfileId, HostedRouteCoordinate Origin, + HostedRouteCoordinate Destination, IReadOnlyList Anchors, + string SelectedProfileAuthorityIdentity); + +public sealed record HostedRouteResponse(bool Succeeded, string Outcome, IReadOnlyList? Geometry, + double? DistanceMetres, double? DurationSeconds, IReadOnlyList? Instructions, + DateTimeOffset? GeneratedAt, string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity, + Guid? TransportProfileId, IReadOnlyList? MatchPoints, + IReadOnlyList? Attribution, string? StorageMode, + string? SelectedProfileAuthorityIdentity) +{ + public static HostedRouteResponse ValidForTest(Guid profileId, string selectedAuthorityIdentity) => new( + true, "available", [new(23, 37), new(23.01, 37.01)], 1500, 900, + [new("Continue", "continue", 0, 1, 1500, 900)], DateTimeOffset.UtcNow, "geoapify", + Guid.Parse("22222222-2222-2222-2222-222222222222"), "mapping", profileId, + [new(23, 37), new(23.01, 37.01)], [new("Powered by Wayfarer test", "https://example.test")], + "persistent", selectedAuthorityIdentity); +} + +public enum HostedProfileSelectionKind { Selected, RequiresChoice } +public sealed record HostedProfileSelection(HostedProfileSelectionKind Kind, HostedRoutingProfile? Profile, + IReadOnlyList Choices); + +public static class HostedProfileSelector +{ + public static HostedProfileSelection Select(Guid? savedProfileId, string? modeKey, string? category, + HostedRoutingCatalog catalog) + { + if (savedProfileId is { } id) + { + var exact = catalog.Profiles.SingleOrDefault(item => item.TransportProfileId == id); + if (exact != null) return new(HostedProfileSelectionKind.Selected, exact, catalog.Profiles); + } + + var matches = catalog.Profiles.Where(item => TextMatches(item, modeKey, category)).ToArray(); + return matches.Length == 1 + ? new(HostedProfileSelectionKind.Selected, matches[0], catalog.Profiles) + : new(HostedProfileSelectionKind.RequiresChoice, null, catalog.Profiles); + } + + private static bool TextMatches(HostedRoutingProfile item, string? modeKey, string? category) => + (!string.IsNullOrWhiteSpace(modeKey) && string.Equals(item.ModeKey, modeKey, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrWhiteSpace(category) && string.Equals(item.Category, category, StringComparison.OrdinalIgnoreCase)); +} + +public enum HostedRoutingOutcome { Success, Unavailable, RequiresChoice, CatalogChanged, InvalidResponse, Stale, Cancelled } +public sealed record HostedRoutingResult(HostedRoutingOutcome Outcome, NavigationRoute? Route = null, + IReadOnlyList? Choices = null, HostedRouteCandidate? Candidate = null, + string? DiscoveryCatalogIdentity = null); + +public sealed record HostedRouteCapabilityMetadata(string Provider, Guid ProviderConfigurationId, + string MappingIdentity, string StorageMode); + +public sealed record HostedRouteCandidate(NavigationRoute Route, HostedRouteRequestContext Context, + Guid SelectedProfileId, string SelectedProfileAuthorityIdentity, HostedRouteCapabilityMetadata Metadata, + DateTimeOffset GeneratedAt); + +public sealed record HostedRouteRequestContext(Guid? SavedTransportProfileId, string? ModeKey, string? Category, + HostedRouteCoordinate Origin, HostedRouteCoordinate Destination, IReadOnlyList Anchors, + string DestinationName, long Generation, long AuthenticationSessionRevision, string NormalizedServer, + string TargetAssociation, string NavigationChoice, Guid? SegmentId = null, + string? ExpectedCatalogIdentity = null) +{ + public static HostedRouteRequestContext ForTest(Guid profileId, string? expectedCatalogIdentity = null) => new( + profileId, "walk", "active", new(23, 37), new(23.01, 37.01), [], "Target", 1, + 1, "https://wayfarer.test", "place:test", "hosted", ExpectedCatalogIdentity: expectedCatalogIdentity); +} + +public sealed record HostedRouteLiveAuthority( + long Generation, + long AuthenticationSessionRevision, + string NormalizedServer, + HostedRouteCoordinate Origin, + HostedRouteCoordinate Destination, + IReadOnlyList Anchors, + string TargetAssociation, + Guid? SegmentId, + Guid? SavedTransportProfileId, + string? ModeKey, + string? Category, + Guid? SelectedTransportProfileId, + string? SelectedProfileAuthorityIdentity, + string NavigationChoice); + +public sealed record HostedRouteSelection(long Generation, Guid TransportProfileId, + string SelectedProfileAuthorityIdentity); + +public sealed record HostedTripTargetAuthority( + Guid DestinationPlaceId, + Guid? SegmentId, + HostedRouteCoordinate Destination, + Guid? SavedTransportProfileId, + string ModeKey, + string Category, + IReadOnlyList Anchors) +{ + public static HostedTripTargetAuthority? Resolve(TripDetails? trip, Guid destinationPlaceId, + double originLatitude, double originLongitude) + { + var destination = trip?.AllPlaces.SingleOrDefault(place => place.Id == destinationPlaceId); + if (destination == null) return null; + + var places = trip!.AllPlaces.ToDictionary(place => place.Id); + var segment = trip.Segments + .Where(item => item.DestinationId == destinationPlaceId && item.OriginId is { } originId + && places.ContainsKey(originId)) + .OrderBy(item => + { + var origin = places[item.OriginId!.Value]; + return GeoMath.CalculateDistance(originLatitude, originLongitude, + origin.Latitude, origin.Longitude); + }) + .FirstOrDefault(); + var anchors = ResolveAnchors(segment, places); + if (anchors == null) return null; + var mode = segment?.TransportMode ?? "walk"; + return new(destinationPlaceId, segment?.Id, + new(destination.Longitude, destination.Latitude), + HostedSegmentProfileIdentity.Get(segment), mode, mode, anchors); + } + + private static IReadOnlyList? ResolveAnchors(TripSegment? segment, + IReadOnlyDictionary places) + { + if (segment == null || segment.Waypoints.Count == 0) return []; + if (segment.Waypoints.Count > 3) return null; + var anchors = new List(segment.Waypoints.Count); + foreach (var waypoint in segment.Waypoints.OrderBy(item => item.Position)) + { + if (!places.TryGetValue(waypoint.PlaceId, out var place)) return null; + anchors.Add(new(place.Longitude, place.Latitude)); + } + return anchors; + } +} + +public static class HostedRouteIdentity +{ + public static IReadOnlyList Canonicalize(IEnumerable points) => points + .SelectMany(point => new[] { Scale(point.Longitude, 180), Scale(point.Latitude, 90) }).ToArray(); + + private static long Scale(double value, double bound) + { + if (!double.IsFinite(value) || value < -bound || value > bound) throw new ArgumentOutOfRangeException(nameof(value)); + if (value == 0) value = 0; + return decimal.ToInt64(decimal.Round((decimal)value * 100000m, 0, MidpointRounding.AwayFromZero)); + } +} + +public static class HostedOpaqueIdentity +{ + public static bool IsValid(string? value) + { + if (value is not { Length: 46 } || !value.StartsWith("v1.", StringComparison.Ordinal) + || value.Any(character => character > 127)) return false; + var payload = value[3..]; + if (payload.Any(character => !(char.IsAsciiLetterOrDigit(character) || character is '-' or '_'))) + return false; + Span decoded = stackalloc byte[32]; + if (!Convert.TryFromBase64String(payload.Replace('-', '+').Replace('_', '/') + "=", decoded, out var written) + || written != 32) return false; + var canonical = Convert.ToBase64String(decoded).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + return string.Equals(payload, canonical, StringComparison.Ordinal); + } +} + +public static class HostedRoutePublication +{ + public static bool TryPublish(HostedRouteCandidate candidate, HostedRouteLiveAuthority live, + NavigationRoute target) + { + if (!Current(candidate, live)) return false; + Copy(candidate, target); + return true; + } + + public static bool Current(HostedRouteCandidate candidate, HostedRouteLiveAuthority live) + { + var expected = candidate.Context; + return live.Generation == expected.Generation + && live.AuthenticationSessionRevision == expected.AuthenticationSessionRevision + && live.NormalizedServer == expected.NormalizedServer + && live.TargetAssociation == expected.TargetAssociation + && live.SegmentId == expected.SegmentId + && live.NavigationChoice == expected.NavigationChoice + && live.NavigationChoice == "hosted" + && live.SavedTransportProfileId == expected.SavedTransportProfileId + && live.ModeKey == expected.ModeKey + && live.Category == expected.Category + && live.SelectedTransportProfileId == candidate.SelectedProfileId + && live.SelectedProfileAuthorityIdentity == candidate.SelectedProfileAuthorityIdentity + && HostedRouteIdentity.Canonicalize(Points(live.Origin, live.Anchors, live.Destination)) + .SequenceEqual(HostedRouteIdentity.Canonicalize( + Points(expected.Origin, expected.Anchors, expected.Destination))); + } + + private static IEnumerable Points(HostedRouteCoordinate origin, + IReadOnlyList anchors, HostedRouteCoordinate destination) => + new[] { origin }.Concat(anchors).Append(destination); + + private static void Copy(HostedRouteCandidate candidate, NavigationRoute target) + { + var source = candidate.Route; + target.Waypoints = source.Waypoints; + target.Steps = source.Steps; + target.DestinationName = source.DestinationName; + target.TotalDistanceMeters = source.TotalDistanceMeters; + target.EstimatedDuration = source.EstimatedDuration; + target.IsDirectRoute = false; + target.InitialBearing = 0; + target.Attribution = source.Attribution; + target.HostedProvenance = new(candidate.SelectedProfileId, + candidate.SelectedProfileAuthorityIdentity, + candidate.Metadata.Provider, + candidate.Metadata.ProviderConfigurationId, + candidate.Metadata.MappingIdentity, + candidate.Metadata.StorageMode, + candidate.GeneratedAt.ToUniversalTime()); + } +} + +public interface IHostedRoutingApiClient +{ + Task DiscoverAsync(CancellationToken cancellationToken); + Task GetCapabilityAsync(Guid profileId, string discoveryCatalogIdentity, + CancellationToken cancellationToken); + Task GetRouteAsync(HostedRouteRequest request, CancellationToken cancellationToken); +} diff --git a/src/WayfarerMobile/Services/HostedRoutingService.cs b/src/WayfarerMobile/Services/HostedRoutingService.cs new file mode 100644 index 0000000..cc04afd --- /dev/null +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -0,0 +1,204 @@ +using Microsoft.Extensions.Logging; +using WayfarerMobile.Core.Models; + +namespace WayfarerMobile.Services; + +/// Validates transient authenticated hosted-route candidates before coordinator publication. +public sealed class HostedRoutingService +{ + private const int MaximumGeometry = 10000; + private const int MaximumInstructions = 1000; + private readonly IHostedRoutingApiClient api; + private readonly ILogger logger; + private readonly object stateLock = new(); + private long activeGeneration; + private HostedRouteSelection? currentSelection; + public bool IsLoading { get; private set; } + + public HostedRouteSelection? CurrentSelection + { + get { lock (stateLock) return currentSelection; } + } + + public HostedRoutingService(IHostedRoutingApiClient api, ILogger logger) + { + this.api = api; + this.logger = logger; + } + + public async Task RequestRouteAsync(HostedRouteRequestContext context, + HostedRoutingProfile? explicitChoice = null, CancellationToken cancellationToken = default, + bool allowCatalogRediscovery = true) + { + if (!Begin(context)) return new(HostedRoutingOutcome.Stale); + try + { + HostedRoutingProfile selectedProfile; + string catalogIdentity; + if (explicitChoice == null) + { + var catalog = await api.DiscoverAsync(cancellationToken); + if (!AvailableCatalog(catalog)) return new(HostedRoutingOutcome.Unavailable); + var selection = HostedProfileSelector.Select( + context.SavedTransportProfileId, context.ModeKey, context.Category, catalog); + if (selection.Profile == null) + return new(HostedRoutingOutcome.RequiresChoice, Choices: selection.Choices, + DiscoveryCatalogIdentity: catalog.DiscoveryCatalogIdentity); + selectedProfile = selection.Profile; + catalogIdentity = catalog.DiscoveryCatalogIdentity!; + } + else + { + if (!ValidProfile(explicitChoice) + || !HostedOpaqueIdentity.IsValid(context.ExpectedCatalogIdentity)) + return new(HostedRoutingOutcome.Unavailable); + selectedProfile = explicitChoice; + catalogIdentity = context.ExpectedCatalogIdentity!; + } + + var capability = await api.GetCapabilityAsync( + selectedProfile.TransportProfileId, catalogIdentity, cancellationToken); + if (capability.Outcome == "catalog-changed") + return allowCatalogRediscovery + ? await RefreshCatalogAsync(cancellationToken) + : new(HostedRoutingOutcome.Unavailable); + if (!ValidCapability(capability, selectedProfile.TransportProfileId, catalogIdentity)) + return new(HostedRoutingOutcome.Unavailable); + var request = new HostedRouteRequest(selectedProfile.TransportProfileId, context.Origin, + context.Destination, context.Anchors, capability.SelectedProfileAuthorityIdentity!); + var response = await api.GetRouteAsync(request, cancellationToken); + if (!ValidResponse(response, request, capability)) return new(HostedRoutingOutcome.InvalidResponse); + if (!SelectCurrent(context.Generation, selectedProfile.TransportProfileId, + capability.SelectedProfileAuthorityIdentity!)) + return new(HostedRoutingOutcome.Stale); + var metadata = new HostedRouteCapabilityMetadata(capability.Provider!, + capability.ProviderConfigurationId!.Value, capability.MappingIdentity!, capability.StorageMode!); + var candidate = new HostedRouteCandidate(BuildRoute(response, context.DestinationName), context, + selectedProfile.TransportProfileId, capability.SelectedProfileAuthorityIdentity!, metadata, + response.GeneratedAt!.Value); + return new(HostedRoutingOutcome.Success, Candidate: candidate); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return new(HostedRoutingOutcome.Cancelled); + } + catch (Exception) + { + logger.LogWarning("Hosted routing failed locally: transport-or-contract-error"); + return new(HostedRoutingOutcome.Unavailable); + } + finally + { + lock (stateLock) + if (activeGeneration == context.Generation) IsLoading = false; + } + } + + public void SelectDirect(long generation) + { + lock (stateLock) + { + activeGeneration = generation; + currentSelection = null; + IsLoading = false; + } + } + + private bool Begin(HostedRouteRequestContext context) + { + lock (stateLock) + { + if (context.Generation < activeGeneration) return false; + activeGeneration = context.Generation; + currentSelection = null; + IsLoading = true; + return true; + } + } + + private bool SelectCurrent(long generation, Guid profileId, string authorityIdentity) + { + lock (stateLock) + { + if (activeGeneration != generation) return false; + currentSelection = new(generation, profileId, authorityIdentity); + return true; + } + } + + private async Task RefreshCatalogAsync(CancellationToken cancellationToken) + { + var catalog = await api.DiscoverAsync(cancellationToken); + return AvailableCatalog(catalog) + ? new(HostedRoutingOutcome.CatalogChanged, Choices: catalog.Profiles, + DiscoveryCatalogIdentity: catalog.DiscoveryCatalogIdentity) + : new(HostedRoutingOutcome.Unavailable); + } + + private static bool AvailableCatalog(HostedRoutingCatalog value) => value.Outcome == "available" + && HostedOpaqueIdentity.IsValid(value.DiscoveryCatalogIdentity) && value.Profiles.Count is > 0 and <= 100 + && value.Profiles.Select(item => item.TransportProfileId).Distinct().Count() == value.Profiles.Count + && value.Profiles.All(ValidProfile); + + private static bool ValidProfile(HostedRoutingProfile item) => item.TransportProfileId != Guid.Empty + && Bounded(item.DisplayName, 200) && Bounded(item.ModeKey, 100) && Bounded(item.Category, 100); + + private static bool ValidCapability(HostedRoutingCapability value, Guid profileId, string catalogIdentity) => + value.Outcome == "available" && value.TransportProfileId == profileId + && value.DiscoveryCatalogIdentity == catalogIdentity + && HostedOpaqueIdentity.IsValid(value.DiscoveryCatalogIdentity) + && HostedOpaqueIdentity.IsValid(value.SelectedProfileAuthorityIdentity) + && Bounded(value.Provider, 100) && value.ProviderConfigurationId is { } id && id != Guid.Empty + && Bounded(value.MappingIdentity, 200) && Bounded(value.StorageMode, 100) + && ValidAttribution(value.Attribution); + + private static bool ValidResponse(HostedRouteResponse value, HostedRouteRequest request, + HostedRoutingCapability capability) + { + if (!value.Succeeded || value.Outcome != "available" || value.TransportProfileId != request.TransportProfileId + || value.SelectedProfileAuthorityIdentity != request.SelectedProfileAuthorityIdentity + || !HostedOpaqueIdentity.IsValid(value.SelectedProfileAuthorityIdentity) + || value.Provider != capability.Provider + || value.ProviderConfigurationId != capability.ProviderConfigurationId + || value.MappingIdentity != capability.MappingIdentity || value.StorageMode != capability.StorageMode + || value.GeneratedAt is not { } generatedAt || generatedAt.Offset != TimeSpan.Zero + || value.Geometry is not { Count: >= 2 and <= MaximumGeometry } + || value.MatchPoints is null || value.DistanceMetres is not double distance || distance < 0 || !double.IsFinite(distance) + || value.DurationSeconds is not double duration || duration < 0 || !double.IsFinite(duration) + || value.Instructions is null || value.Instructions.Count > MaximumInstructions + || !ValidAttribution(value.Attribution) || !Bounded(value.StorageMode, 100)) return false; + var inputs = new[] { request.Origin }.Concat(request.Anchors).Append(request.Destination).ToArray(); + return value.MatchPoints.SequenceEqual(inputs) && value.Geometry.All(ValidCoordinate) + && value.Instructions.All(item => Bounded(item.Text, 500) && Bounded(item.Type, 100) + && item.FromIndex >= 0 && item.ToIndex >= item.FromIndex && item.ToIndex < value.Geometry.Count + && double.IsFinite(item.DistanceMetres) && item.DistanceMetres >= 0 + && double.IsFinite(item.DurationSeconds) && item.DurationSeconds >= 0); + } + + private static NavigationRoute BuildRoute(HostedRouteResponse value, string destinationName) => new() + { + Waypoints = value.Geometry!.Select((item, index) => new NavigationWaypoint + { + Longitude = item.Longitude, Latitude = item.Latitude, + Name = index == value.Geometry!.Count - 1 ? destinationName : string.Empty + }).ToList(), + Steps = value.Instructions!.Select(item => new NavigationStep + { + Instruction = item.Text, ManeuverType = item.Type, DistanceMeters = item.DistanceMetres, + DurationSeconds = item.DurationSeconds, Longitude = value.Geometry![item.FromIndex].Longitude, + Latitude = value.Geometry![item.FromIndex].Latitude + }).ToList(), + DestinationName = destinationName, + TotalDistanceMeters = value.DistanceMetres!.Value, + EstimatedDuration = TimeSpan.FromSeconds(value.DurationSeconds!.Value), + IsDirectRoute = false, + Attribution = value.Attribution!.ToList() + }; + + private static bool ValidCoordinate(HostedRouteCoordinate item) => double.IsFinite(item.Longitude) + && double.IsFinite(item.Latitude) && item.Longitude is >= -180 and <= 180 && item.Latitude is >= -90 and <= 90; + private static bool ValidAttribution(IReadOnlyList? value) => value is { Count: > 0 and <= 10 } + && value.All(item => Bounded(item.Text, 200) && Bounded(item.Url, 500) + && Uri.TryCreate(item.Url, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps); + private static bool Bounded(string? value, int maximum) => value is { Length: > 0 } && value.Length <= maximum; +} diff --git a/src/WayfarerMobile/Services/HostedSegmentProfileIdentity.cs b/src/WayfarerMobile/Services/HostedSegmentProfileIdentity.cs new file mode 100644 index 0000000..d083bfb --- /dev/null +++ b/src/WayfarerMobile/Services/HostedSegmentProfileIdentity.cs @@ -0,0 +1,24 @@ +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization.Metadata; +using WayfarerMobile.Core.Models; + +namespace WayfarerMobile.Services; + +/// Attaches the current server-owned Segment profile identity without persisting it. +public static class HostedSegmentProfileIdentity +{ + private sealed class Holder { public Guid? Value { get; set; } } + private static readonly ConditionalWeakTable Values = new(); + + public static Guid? Get(TripSegment? segment) => + segment != null && Values.TryGetValue(segment, out var holder) ? holder.Value : null; + + public static void Configure(JsonTypeInfo typeInfo) + { + if (typeInfo.Type != typeof(TripSegment)) return; + var property = typeInfo.CreateJsonPropertyInfo(typeof(Guid?), "transportProfileId"); + property.Get = value => Get((TripSegment)value); + property.Set = (value, profileId) => Values.GetOrCreateValue((TripSegment)value).Value = (Guid?)profileId; + typeInfo.Properties.Add(property); + } +} diff --git a/src/WayfarerMobile/Services/SettingsService.Authentication.cs b/src/WayfarerMobile/Services/SettingsService.Authentication.cs new file mode 100644 index 0000000..36a472a --- /dev/null +++ b/src/WayfarerMobile/Services/SettingsService.Authentication.cs @@ -0,0 +1,164 @@ +using Microsoft.Extensions.Logging; + +namespace WayfarerMobile.Services; + +public partial class SettingsService +{ + // Cached values avoid blocking SecureStorage calls on the main thread. + private string? _cachedServerUrl; + private string? _cachedApiToken; + private bool _serverUrlLoaded; + private bool _apiTokenLoaded; + private long _authenticationSessionRevision; + + /// + public long AuthenticationSessionRevision => Interlocked.Read(ref _authenticationSessionRevision); + + /// + /// Pre-loads secure settings from SecureStorage into memory cache. + /// Call this at app startup to avoid blocking on first access. + /// + public async Task PreloadSecureSettingsAsync() + { + if (!_serverUrlLoaded) + { + try + { + SetCachedServerUrl(await SecureStorage.Default.GetAsync(KeyServerUrl)); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "SecureStorage unavailable for ServerUrl"); + SetCachedServerUrl(null); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unexpected error loading ServerUrl"); + SetCachedServerUrl(null); + } + _serverUrlLoaded = true; + } + if (!_apiTokenLoaded) + { + try + { + SetCachedApiToken(await SecureStorage.Default.GetAsync(KeyApiToken)); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "SecureStorage unavailable for ApiToken"); + SetCachedApiToken(null); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unexpected error loading ApiToken"); + SetCachedApiToken(null); + } + _apiTokenLoaded = true; + } + } + + /// Gets or sets the server URL used for API calls. + public string? ServerUrl + { + get + { + if (!_serverUrlLoaded) + { + try + { + SetCachedServerUrl(Task.Run(async () => + await SecureStorage.Default.GetAsync(KeyServerUrl)).Result); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "SecureStorage unavailable for ServerUrl (sync)"); + SetCachedServerUrl(null); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unexpected error loading ServerUrl (sync)"); + SetCachedServerUrl(null); + } + _serverUrlLoaded = true; + } + return _cachedServerUrl; + } + set + { + SetCachedServerUrl(value); + _serverUrlLoaded = true; + if (string.IsNullOrEmpty(value)) + { + SecureStorage.Default.Remove(KeyServerUrl); + } + else + { + Task.Run(async () => await SecureStorage.Default.SetAsync(KeyServerUrl, value)); + } + } + } + + /// Gets or sets the API authentication token. + public string? ApiToken + { + get + { + if (!_apiTokenLoaded) + { + try + { + SetCachedApiToken(Task.Run(async () => + await SecureStorage.Default.GetAsync(KeyApiToken)).Result); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "SecureStorage unavailable for ApiToken (sync)"); + SetCachedApiToken(null); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unexpected error loading ApiToken (sync)"); + SetCachedApiToken(null); + } + _apiTokenLoaded = true; + } + return _cachedApiToken; + } + set + { + SetCachedApiToken(value); + _apiTokenLoaded = true; + if (string.IsNullOrEmpty(value)) + { + SecureStorage.Default.Remove(KeyApiToken); + } + else + { + Task.Run(async () => await SecureStorage.Default.SetAsync(KeyApiToken, value)); + } + } + } + + private void SetCachedServerUrl(string? value) + { + if (string.Equals(_cachedServerUrl, value, StringComparison.Ordinal)) return; + _cachedServerUrl = value; + Interlocked.Increment(ref _authenticationSessionRevision); + } + + private void SetCachedApiToken(string? value) + { + if (string.Equals(_cachedApiToken, value, StringComparison.Ordinal)) return; + _cachedApiToken = value; + Interlocked.Increment(ref _authenticationSessionRevision); + } + + private void ClearCachedAuthentication() + { + if (_cachedServerUrl == null && _cachedApiToken == null) return; + _cachedServerUrl = null; + _cachedApiToken = null; + Interlocked.Increment(ref _authenticationSessionRevision); + } +} diff --git a/src/WayfarerMobile/Services/SettingsService.cs b/src/WayfarerMobile/Services/SettingsService.cs index 28e09c1..a009d93 100644 --- a/src/WayfarerMobile/Services/SettingsService.cs +++ b/src/WayfarerMobile/Services/SettingsService.cs @@ -6,7 +6,7 @@ namespace WayfarerMobile.Services; /// /// Service for managing application settings using MAUI Preferences. /// -public class SettingsService : ISettingsService +public partial class SettingsService : ISettingsService { private readonly ILogger _logger; @@ -97,152 +97,6 @@ public bool BackgroundTrackingEnabled set => Preferences.Set(KeyBackgroundTrackingEnabled, value); } - // Cached values to avoid blocking SecureStorage calls on main thread - private string? _cachedServerUrl; - private string? _cachedApiToken; - private bool _serverUrlLoaded; - private bool _apiTokenLoaded; - - /// - /// Pre-loads secure settings from SecureStorage into memory cache. - /// Call this at app startup to avoid blocking on first access. - /// - public async Task PreloadSecureSettingsAsync() - { - if (!_serverUrlLoaded) - { - try - { - _cachedServerUrl = await SecureStorage.Default.GetAsync(KeyServerUrl); - } - catch (InvalidOperationException ex) - { - // SecureStorage unavailable (common after data clear on Android) - _logger.LogWarning(ex, "SecureStorage unavailable for ServerUrl"); - _cachedServerUrl = null; - } - catch (Exception ex) - { - // Other platform-specific failures - treat as empty - _logger.LogWarning(ex, "Unexpected error loading ServerUrl"); - _cachedServerUrl = null; - } - _serverUrlLoaded = true; - } - if (!_apiTokenLoaded) - { - try - { - _cachedApiToken = await SecureStorage.Default.GetAsync(KeyApiToken); - } - catch (InvalidOperationException ex) - { - // SecureStorage unavailable (common after data clear on Android) - _logger.LogWarning(ex, "SecureStorage unavailable for ApiToken"); - _cachedApiToken = null; - } - catch (Exception ex) - { - // Other platform-specific failures - treat as empty - _logger.LogWarning(ex, "Unexpected error loading ApiToken"); - _cachedApiToken = null; - } - _apiTokenLoaded = true; - } - } - - /// - /// Gets or sets the server URL for API calls. - /// Cached in memory to avoid SecureStorage deadlocks on Android. - /// - public string? ServerUrl - { - get - { - if (!_serverUrlLoaded) - { - try - { - // First access - load from SecureStorage on background thread - _cachedServerUrl = Task.Run(async () => await SecureStorage.Default.GetAsync(KeyServerUrl)).Result; - } - catch (InvalidOperationException ex) - { - // SecureStorage unavailable (common after data clear on Android) - _logger.LogWarning(ex, "SecureStorage unavailable for ServerUrl (sync)"); - _cachedServerUrl = null; - } - catch (Exception ex) - { - // Other platform-specific failures - treat as empty - _logger.LogWarning(ex, "Unexpected error loading ServerUrl (sync)"); - _cachedServerUrl = null; - } - _serverUrlLoaded = true; - } - return _cachedServerUrl; - } - set - { - _cachedServerUrl = value; - _serverUrlLoaded = true; - if (string.IsNullOrEmpty(value)) - { - SecureStorage.Default.Remove(KeyServerUrl); - } - else - { - Task.Run(async () => await SecureStorage.Default.SetAsync(KeyServerUrl, value)); - } - } - } - - /// - /// Gets or sets the API authentication token. - /// Cached in memory to avoid SecureStorage deadlocks on Android. - /// - public string? ApiToken - { - get - { - if (!_apiTokenLoaded) - { - try - { - // First access - load from SecureStorage on background thread - _cachedApiToken = Task.Run(async () => await SecureStorage.Default.GetAsync(KeyApiToken)).Result; - } - catch (InvalidOperationException ex) - { - // SecureStorage unavailable (common after data clear on Android) - _logger.LogWarning(ex, "SecureStorage unavailable for ApiToken (sync)"); - _cachedApiToken = null; - } - catch (Exception ex) - { - // Other platform-specific failures - treat as empty - _logger.LogWarning(ex, "Unexpected error loading ApiToken (sync)"); - _cachedApiToken = null; - } - _apiTokenLoaded = true; - } - return _cachedApiToken; - } - set - { - _cachedApiToken = value; - _apiTokenLoaded = true; - if (string.IsNullOrEmpty(value)) - { - SecureStorage.Default.Remove(KeyApiToken); - } - else - { - Task.Run(async () => await SecureStorage.Default.SetAsync(KeyApiToken, value)); - } - } - } - /// /// Gets or sets the minimum time between logged locations (from server config). /// Default: 5 minutes. @@ -688,6 +542,7 @@ public void ClearSyncReference() public void Clear() { Preferences.Clear(); + ClearAuth(); // Reset first run to false since app was used IsFirstRun = false; } @@ -702,8 +557,7 @@ public void ClearAuth() SecureStorage.Default.Remove(KeyServerUrl); // Clear cached values - _cachedApiToken = null; - _cachedServerUrl = null; + ClearCachedAuthentication(); _apiTokenLoaded = true; _serverUrlLoaded = true; @@ -748,8 +602,7 @@ public void ResetToDefaults() } // Reset cached values - _cachedServerUrl = null; - _cachedApiToken = null; + ClearCachedAuthentication(); _serverUrlLoaded = true; _apiTokenLoaded = true; diff --git a/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs b/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs index 0fa5ee5..8819255 100644 --- a/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs +++ b/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs @@ -212,6 +212,7 @@ private async Task NavigateToContextLocationAsync() var travelProfile = navMethod switch { + NavigationMethod.Direct => "direct", NavigationMethod.Walk => "foot", NavigationMethod.Drive => "car", NavigationMethod.Bike => "bike", diff --git a/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs index e25f99f..1058262 100644 --- a/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs +++ b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs @@ -7,6 +7,7 @@ using WayfarerMobile.Core.Models; using WayfarerMobile.Helpers; using WayfarerMobile.Interfaces; +using WayfarerMobile.Services; using WayfarerMobile.Views.Controls; namespace WayfarerMobile.ViewModels; @@ -20,7 +21,7 @@ public partial class MemberDetailsViewModel : ObservableObject #region Fields private readonly IToastService _toastService; - private readonly ITripNavigationService _tripNavigationService; + private readonly NavigationCoordinatorViewModel _navigationCoordinator; private readonly ILogger _logger; private IMemberDetailsCallbacks? _callbacks; @@ -68,15 +69,15 @@ public partial class MemberDetailsViewModel : ObservableObject /// Creates a new instance of MemberDetailsViewModel. /// /// Toast notification service. - /// Navigation service for routing. + /// Shared owner for Direct and hosted routing. /// Logger instance. public MemberDetailsViewModel( IToastService toastService, - ITripNavigationService tripNavigationService, + NavigationCoordinatorViewModel navigationCoordinator, ILogger logger) { _toastService = toastService; - _tripNavigationService = tripNavigationService; + _navigationCoordinator = navigationCoordinator; _logger = logger; } @@ -313,6 +314,7 @@ await OpenExternalMapsAsync( var travelProfile = navMethod switch { + NavigationMethod.Direct => "direct", NavigationMethod.Walk => "foot", NavigationMethod.Drive => "car", NavigationMethod.Bike => "bike", @@ -324,16 +326,19 @@ await OpenExternalMapsAsync( var destLat = SelectedMember.LastLocation.Latitude; var destLon = SelectedMember.LastLocation.Longitude; var destName = SelectedMember.DisplayText ?? "Member"; + var targetUserId = SelectedMember.UserId; _logger.LogInformation("Calculating Direct guidance to member using {Mode}", travelProfile); - var route = await _tripNavigationService.CalculateRouteToCoordinatesAsync( + var route = await _navigationCoordinator.CalculateHostedRouteToCoordinatesAsync( currentLocation.Latitude, currentLocation.Longitude, destLat, destLon, destName, - travelProfile); + travelProfile, + $"group-member:{targetUserId}", + () => ResolveCurrentMemberLocation(targetUserId)); // Close bottom sheet before navigating IsMemberSheetOpen = false; @@ -347,13 +352,20 @@ await OpenExternalMapsAsync( _logger.LogInformation("Started navigation to {Member}: {Distance:F1}km", destName, route.TotalDistanceMeters / 1000); } - catch (Exception ex) + catch (Exception) { - _logger.LogError(ex, "Unexpected error starting navigation"); + _logger.LogError("Member navigation failed: local-navigation-error"); await _toastService.ShowErrorAsync("Failed to start navigation"); } } + private HostedRouteCoordinate? ResolveCurrentMemberLocation(string userId) + { + var location = _callbacks?.Members + .FirstOrDefault(member => member.UserId == userId)?.LastLocation; + return location == null ? null : new(location.Longitude, location.Latitude); + } + #endregion #region Private Methods diff --git a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs index 3025a81..698e56b 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -1,9 +1,11 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; +using Microsoft.Maui.ApplicationModel; using WayfarerMobile.Core.Enums; using WayfarerMobile.Core.Interfaces; using WayfarerMobile.Core.Models; +using WayfarerMobile.Services; namespace WayfarerMobile.ViewModels; @@ -20,6 +22,14 @@ public partial class NavigationCoordinatorViewModel : BaseViewModel private readonly NavigationHudViewModel _navigationHudViewModel; private readonly IVisitNotificationService _visitNotificationService; private readonly ILogger _logger; + private readonly HostedRoutingService _hostedRouting; + private readonly ISettingsService _settings; + private readonly IDialogService _dialogs; + private readonly ITripStateManager _tripState; + private CancellationTokenSource? _hostedRoutingCancellation; + private long _hostedRoutingGeneration; + private HostedRouteRequestContext? _hostedRequest; + private HostedRouteTargetOwner? _hostedTargetOwner; // Callbacks to parent ViewModel private INavigationCallbacks? _callbacks; @@ -72,11 +82,19 @@ public NavigationCoordinatorViewModel( ITripNavigationService tripNavigationService, NavigationHudViewModel navigationHudViewModel, IVisitNotificationService visitNotificationService, + HostedRoutingService hostedRouting, + ISettingsService settings, + IDialogService dialogs, + ITripStateManager tripState, ILogger logger) { _tripNavigationService = tripNavigationService; _navigationHudViewModel = navigationHudViewModel; _visitNotificationService = visitNotificationService; + _hostedRouting = hostedRouting; + _settings = settings; + _dialogs = dialogs; + _tripState = tripState; _logger = logger; // Subscribe to HUD stop navigation request @@ -120,11 +138,25 @@ public async Task StartNavigationToPlaceAsync(string placeId) return; } + CancelHostedRouting(); + var route = _tripNavigationService.CalculateRouteToPlace( currentLocation.Latitude, currentLocation.Longitude, placeId); + if (route?.IsDirectRoute == true && Guid.TryParse(placeId, out var destinationId)) + { + var authority = HostedTripTargetAuthority.Resolve(_tripState.LoadedTrip, destinationId, + currentLocation.Latitude, currentLocation.Longitude); + if (authority != null) + { + route = await TryHostedAsync(route, currentLocation.Latitude, currentLocation.Longitude, + authority.Destination.Latitude, authority.Destination.Longitude, route.DestinationName, + authority.ModeKey, authority, HostedRouteTargetOwner.Trip(destinationId)); + } + } + if (route != null) { // Track navigation destination for visit notification conflict detection @@ -154,16 +186,38 @@ public async Task StartNavigationToNextAsync() return; } + CancelHostedRouting(); + var route = _tripNavigationService.CalculateRouteToNextPlace( currentLocation.Latitude, currentLocation.Longitude); + Guid? destinationPlaceId = null; + + if (route?.IsDirectRoute == true && route.Waypoints.Count > 0) + { + var destination = route.Waypoints[^1]; + destinationPlaceId = Guid.TryParse(destination.PlaceId, out var parsedId) ? parsedId : null; + var authority = destinationPlaceId is { } exactId + ? HostedTripTargetAuthority.Resolve(_tripState.LoadedTrip, exactId, + currentLocation.Latitude, currentLocation.Longitude) + : null; + if (authority != null) + { + route = await TryHostedAsync(route, currentLocation.Latitude, currentLocation.Longitude, + authority.Destination.Latitude, authority.Destination.Longitude, route.DestinationName, + authority.ModeKey, authority, HostedRouteTargetOwner.Trip(authority.DestinationPlaceId)); + } + } + else if (route?.Waypoints.Count > 0) + { + destinationPlaceId = Guid.TryParse(route.Waypoints[^1].PlaceId, out var parsedId) ? parsedId : null; + } if (route != null) { // Track navigation destination for visit notification conflict detection - // Note: For "next place" we don't have the place ID readily available - _currentNavigationPlaceId = null; - _visitNotificationService.UpdateNavigationState(true, null); + _currentNavigationPlaceId = destinationPlaceId; + _visitNotificationService.UpdateNavigationState(true, destinationPlaceId); IsNavigating = true; _callbacks?.ShowNavigationRoute(route); @@ -182,6 +236,7 @@ public async Task StartNavigationToNextAsync() [RelayCommand] public void StopNavigation() { + CancelHostedRouting(); _tripNavigationService.StopNavigation(); // Notify visit notification service that navigation ended @@ -251,11 +306,175 @@ public async Task CalculateRouteToCoordinatesAsync( string destinationName, string profile = "foot") { - return await _tripNavigationService.CalculateRouteToCoordinatesAsync( + var direct = await _tripNavigationService.CalculateRouteToCoordinatesAsync( fromLat, fromLon, toLat, toLon, destinationName, profile); + return await TryHostedAsync(direct, fromLat, fromLon, toLat, toLon, destinationName, + profile, null, HostedRouteTargetOwner.Fixed(toLat, toLon, "ad-hoc-coordinates")); + } + + /// Routes a non-Trip target through the shared hosted coordinator path. + public async Task CalculateHostedRouteToCoordinatesAsync( + double fromLat, double fromLon, double toLat, double toLon, string destinationName, + string profile, string targetAssociation, Func currentTarget) + { + var direct = await _tripNavigationService.CalculateRouteToCoordinatesAsync( + fromLat, fromLon, toLat, toLon, destinationName, profile); + return await TryHostedAsync(direct, fromLat, fromLon, toLat, toLon, destinationName, + profile, null, HostedRouteTargetOwner.Member(toLat, toLon, targetAssociation, currentTarget)); + } + + private async Task TryHostedAsync(NavigationRoute direct, double fromLat, double fromLon, + double toLat, double toLon, string destinationName, string profile, + HostedTripTargetAuthority? tripAuthority, HostedRouteTargetOwner targetOwner) + { + var generation = Interlocked.Increment(ref _hostedRoutingGeneration); + CancelHostedRouting(incrementGeneration: false); + if (profile == "direct") + { + _hostedRouting.SelectDirect(generation); + return direct; + } + _hostedRoutingCancellation = new CancellationTokenSource(); + var context = CreateHostedContext(fromLat, fromLon, toLat, toLon, destinationName, profile, + generation, tripAuthority, targetOwner.Association); + _hostedRequest = context; + _hostedTargetOwner = targetOwner; + var catalogRediscoveryAvailable = true; + var result = await _hostedRouting.RequestRouteAsync(context, cancellationToken: _hostedRoutingCancellation.Token); + if (result.Outcome == HostedRoutingOutcome.CatalogChanged) catalogRediscoveryAvailable = false; + var maximumPresentations = result.Outcome switch + { + HostedRoutingOutcome.RequiresChoice => 2, + HostedRoutingOutcome.CatalogChanged => 1, + _ => 0 + }; + for (var presentation = 0; presentation < maximumPresentations + && result.Outcome is HostedRoutingOutcome.RequiresChoice or HostedRoutingOutcome.CatalogChanged; + presentation++) + { + if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation + || result.Choices is not { Count: > 0 } + || !HostedOpaqueIdentity.IsValid(result.DiscoveryCatalogIdentity)) return direct; + var options = result.Choices.Select(item => + $"{item.DisplayName} — {item.ModeKey} ({item.TransportProfileId:D})").ToArray(); + var selected = await _dialogs.SelectAsync("Wayfarer routing profile", options, "Direct"); + var index = selected == null ? -1 : Array.IndexOf(options, selected); + if (index < 0) + { + _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); + return direct; + } + if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation) return direct; + var choiceContext = context with { ExpectedCatalogIdentity = result.DiscoveryCatalogIdentity }; + _hostedRequest = choiceContext; + result = await _hostedRouting.RequestRouteAsync( + choiceContext, result.Choices[index], _hostedRoutingCancellation.Token, + catalogRediscoveryAvailable); + if (result.Outcome == HostedRoutingOutcome.CatalogChanged) catalogRediscoveryAvailable = false; + } + if (result.Outcome == HostedRoutingOutcome.CatalogChanged) + { + _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); + return direct; + } + if (result.Outcome != HostedRoutingOutcome.Success || result.Candidate == null) return direct; + if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation) return direct; + await MainThread.InvokeOnMainThreadAsync(() => + { + var live = CreateLiveAuthority(); + if (live != null) HostedRoutePublication.TryPublish(result.Candidate, live, direct); + }); + return direct; + } + + private HostedRouteRequestContext CreateHostedContext(double fromLat, double fromLon, double toLat, + double toLon, string destinationName, string profile, long generation, + HostedTripTargetAuthority? tripAuthority, string targetAssociation) + { + var mode = NormalizeMode(tripAuthority?.ModeKey ?? profile); + var category = NormalizeMode(tripAuthority?.Category ?? mode); + var server = NormalizeServer(_settings.ServerUrl); + return new(tripAuthority?.SavedTransportProfileId, mode, category, + new(fromLon, fromLat), new(toLon, toLat), tripAuthority?.Anchors ?? [], destinationName, + generation, _settings.AuthenticationSessionRevision, server, targetAssociation, "hosted", + tripAuthority?.SegmentId); + } + + private HostedRouteLiveAuthority? CreateLiveAuthority() + { + var request = _hostedRequest; + var owner = _hostedTargetOwner; + var location = _callbacks?.CurrentLocation; + var selection = _hostedRouting.CurrentSelection; + if (request == null || owner == null || location == null + || selection?.Generation != _hostedRoutingGeneration) return null; + + HostedTripTargetAuthority? tripAuthority = null; + HostedRouteCoordinate? destination; + if (owner.TripPlaceId is { } tripPlaceId) + { + tripAuthority = HostedTripTargetAuthority.Resolve(_tripState.LoadedTrip, tripPlaceId, + location.Latitude, location.Longitude); + destination = tripAuthority?.Destination; + } + else + { + destination = owner.ResolveDestination(); + } + if (destination == null) return null; + + var mode = NormalizeMode(tripAuthority?.ModeKey ?? request.ModeKey ?? string.Empty); + var category = NormalizeMode(tripAuthority?.Category ?? request.Category ?? string.Empty); + return new(_hostedRoutingGeneration, _settings.AuthenticationSessionRevision, + NormalizeServer(_settings.ServerUrl), new(location.Longitude, location.Latitude), destination, + tripAuthority?.Anchors ?? [], owner.Association, tripAuthority?.SegmentId, + tripAuthority?.SavedTransportProfileId, mode, category, selection.TransportProfileId, + selection.SelectedProfileAuthorityIdentity, "hosted"); + } + + private static string NormalizeMode(string profile) => profile switch + { + "foot" or "walking" => "walk", + "car" or "driving" => "drive", + "bike" or "cycling" => "bicycle", + _ => profile + }; + + private static string NormalizeServer(string? value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) return string.Empty; + var authority = uri.GetLeftPart(UriPartial.Authority).ToLowerInvariant(); + return $"{authority}{uri.AbsolutePath}".TrimEnd('/'); + } + + private void CancelHostedRouting(bool incrementGeneration = true) + { + if (incrementGeneration) _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); + _hostedRequest = null; + _hostedTargetOwner = null; + _hostedRoutingCancellation?.Cancel(); + _hostedRoutingCancellation?.Dispose(); + _hostedRoutingCancellation = null; + } + + private sealed record HostedRouteTargetOwner(string Association, Guid? TripPlaceId, + HostedRouteCoordinate InitialDestination, Func? CurrentDestination) + { + public static HostedRouteTargetOwner Fixed(double latitude, double longitude, string association) => + new(association, null, new(longitude, latitude), null); + + public static HostedRouteTargetOwner Member(double latitude, double longitude, string association, + Func currentDestination) => + new(association, null, new(longitude, latitude), currentDestination); + + public static HostedRouteTargetOwner Trip(Guid placeId) => + new($"trip-place:{placeId:D}", placeId, new(0, 0), null); + + public HostedRouteCoordinate? ResolveDestination() => + CurrentDestination == null ? InitialDestination : CurrentDestination(); } /// @@ -263,6 +482,7 @@ public async Task CalculateRouteToCoordinatesAsync( /// public async Task StartNavigationWithRouteAsync(NavigationRoute route) { + CancelHostedRouting(); _currentNavigationPlaceId = null; _visitNotificationService.UpdateNavigationState(true, null); @@ -301,6 +521,7 @@ private void OnStopNavigationRequested(object? sender, string? sourcePageRoute) /// protected override void Cleanup() { + CancelHostedRouting(); _navigationHudViewModel.StopNavigationRequested -= OnStopNavigationRequested; _navigationHudViewModel.Dispose(); base.Cleanup(); diff --git a/src/WayfarerMobile/ViewModels/NavigationHudViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationHudViewModel.cs index 726a8bd..f151ab8 100644 --- a/src/WayfarerMobile/ViewModels/NavigationHudViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationHudViewModel.cs @@ -77,6 +77,10 @@ public partial class NavigationHudViewModel : ObservableObject, IDisposable [ObservableProperty] private string _instructionText = string.Empty; + /// Gets linked attribution for the active hosted route. + [ObservableProperty] + private IReadOnlyList _attribution = []; + /// /// Gets or sets the bearing to destination in degrees. /// @@ -194,6 +198,13 @@ private void ToggleMute() IsMuted = !IsMuted; } + [RelayCommand] + private static async Task OpenAttributionAsync(HostedRouteAttribution attribution) + { + if (Uri.TryCreate(attribution.Url, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps) + await Launcher.Default.OpenAsync(uri); + } + #endregion #region Public Methods @@ -213,6 +224,7 @@ public async Task StartNavigationAsync(NavigationRoute route) StatusColor = "#4285F4"; // Blue IsOffRoute = false; ProgressPercent = 0; + Attribution = route.Attribution; // Reset audio tracking state for fresh navigation _lastAnnouncedStatus = NavigationStatus.NoRoute; @@ -243,6 +255,7 @@ public void StopNavigationDisplay() StatusColor = "#4285F4"; IsOffRoute = false; ProgressPercent = 0; + Attribution = []; // Reset audio tracking state _lastAnnouncedStatus = NavigationStatus.NoRoute; diff --git a/src/WayfarerMobile/Views/Controls/NavigationHud.xaml b/src/WayfarerMobile/Views/Controls/NavigationHud.xaml index c6d71b2..284c9cf 100644 --- a/src/WayfarerMobile/Views/Controls/NavigationHud.xaml +++ b/src/WayfarerMobile/Views/Controls/NavigationHud.xaml @@ -2,7 +2,9 @@ + + + +