From 4655bb03a4cd512cee5638dae24b8c85ed3e08e7 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 12:25:29 +0300 Subject: [PATCH 01/18] WIP: prove authenticated Wayfarer routing contract (checkpoint; tests failing) --- .../Services/HostedRoutingServiceTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs new file mode 100644 index 0000000..e25c27a --- /dev/null +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using WayfarerMobile.Core.Interfaces; +using WayfarerMobile.Core.Models; +using WayfarerMobile.Services; + +namespace WayfarerMobile.Tests.Unit.Services; + +public sealed class HostedRoutingServiceTests +{ + [Fact] + public async Task EligibleRequest_UsesAuthenticatedWayfarerRouteAndReturnsTransientRoute() + { + var api = new Mock(); + var profileId = Guid.NewGuid(); + api.Setup(client => client.GetCapabilityAsync(profileId, It.IsAny())) + .ReturnsAsync(HostedRoutingCapability.Available( + profileId, "provider", Guid.NewGuid(), "mapping", "persistent", [])); + api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(HostedRouteResponse.ValidForTest(profileId)); + var service = new HostedRoutingService(api.Object, NullLogger.Instance); + + var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(profileId)); + + result.Outcome.Should().Be(HostedRoutingOutcome.Success); + result.Route.Should().NotBeNull(); + result.Route!.IsDirectRoute.Should().BeFalse(); + api.Verify(client => client.GetRouteAsync( + It.Is(request => request.TransportProfileId == profileId), + It.IsAny()), Times.Once); + } +} From e7e1d9a3411aaa0e47c5a275a45f58e2b83fc6e9 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 21:26:41 +0300 Subject: [PATCH 02/18] WIP: prove final hosted routing contract (checkpoint; tests failing) --- .../Services/HostedRoutingServiceTests.cs | 94 ++++++++++++++++--- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs index e25c27a..a483e2a 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs @@ -1,32 +1,100 @@ using Microsoft.Extensions.Logging.Abstractions; using Moq; -using WayfarerMobile.Core.Interfaces; -using WayfarerMobile.Core.Models; using WayfarerMobile.Services; namespace WayfarerMobile.Tests.Unit.Services; public sealed class HostedRoutingServiceTests { + private static readonly Guid WalkingProfile = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid CyclingProfile = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + [Theory] + [InlineData(true, "unknown", "unknown", HostedProfileSelectionKind.Selected)] + [InlineData(false, "walk", "hiking", HostedProfileSelectionKind.Selected)] + [InlineData(false, "walk", "active", HostedProfileSelectionKind.RequiresChoice)] + [InlineData(false, "boat", "water", HostedProfileSelectionKind.RequiresChoice)] + public void SelectProfile_UsesGuidThenOnlyAnUnambiguousTextualHint( + bool savedGuidMatches, string modeKey, string category, HostedProfileSelectionKind expected) + { + var catalog = Catalog( + new(WalkingProfile, "Walking", "walk", "active"), + new(CyclingProfile, "Cycling", "bike", "active")); + + var result = HostedProfileSelector.Select( + savedGuidMatches ? WalkingProfile : null, modeKey, category, catalog); + + result.Kind.Should().Be(expected); + if (savedGuidMatches || modeKey == "walk" && category == "hiking") + result.Profile?.TransportProfileId.Should().Be(WalkingProfile); + } + [Fact] - public async Task EligibleRequest_UsesAuthenticatedWayfarerRouteAndReturnsTransientRoute() + public void ConfirmChoice_RejectsCancelledAndStaleCatalogChoices() { - var api = new Mock(); - var profileId = Guid.NewGuid(); - api.Setup(client => client.GetCapabilityAsync(profileId, It.IsAny())) + var original = Catalog(new(WalkingProfile, "Walking", "walk", "active")); + var renamed = new HostedRoutingCatalog("v1.catalog-b", "available", + [new(WalkingProfile, "On foot", "walk", "active")]); + + HostedProfileSelector.Confirm(null, original).Should().BeNull(); + HostedProfileSelector.Confirm(new(WalkingProfile, "Walking", "walk", "active"), renamed) + .Should().BeNull(); + } + + [Fact] + public async Task RequestRouteAsync_UsesCatalogForCapabilityAndSelectedAuthorityForRoute() + { + var api = new Mock(MockBehavior.Strict); + var catalog = Catalog(new(WalkingProfile, "Walking", "walk", "active")); + api.Setup(client => client.DiscoverAsync(It.IsAny())).ReturnsAsync(catalog); + api.Setup(client => client.GetCapabilityAsync( + WalkingProfile, catalog.DiscoveryCatalogIdentity, It.IsAny())) .ReturnsAsync(HostedRoutingCapability.Available( - profileId, "provider", Guid.NewGuid(), "mapping", "persistent", [])); - api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(HostedRouteResponse.ValidForTest(profileId)); + WalkingProfile, catalog.DiscoveryCatalogIdentity, "v1.selected-a", [])); + api.Setup(client => client.GetRouteAsync( + It.Is(request => request.TransportProfileId == WalkingProfile + && request.SelectedProfileAuthorityIdentity == "v1.selected-a"), + It.IsAny())) + .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, "v1.selected-a")); var service = new HostedRoutingService(api.Object, NullLogger.Instance); - var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(profileId)); + var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest( + WalkingProfile, expectedCatalogIdentity: catalog.DiscoveryCatalogIdentity)); result.Outcome.Should().Be(HostedRoutingOutcome.Success); result.Route.Should().NotBeNull(); result.Route!.IsDirectRoute.Should().BeFalse(); - api.Verify(client => client.GetRouteAsync( - It.Is(request => request.TransportProfileId == profileId), - It.IsAny()), Times.Once); + result.Route.Attribution.Should().ContainSingle(item => item.Text == "Powered by Wayfarer test"); + api.VerifyAll(); } + + [Fact] + public async Task RequestRouteAsync_UnrelatedCatalogChangeAfterCapability_DoesNotInvalidateSelectedAuthority() + { + var api = HostedRoutingApiMock.CreateSuccessful(WalkingProfile, "v1.catalog-a", "v1.selected-a"); + var state = HostedRoutingState.ForTest(catalogIdentity: "v1.catalog-b", selectedAuthorityIdentity: "v1.selected-a"); + var service = new HostedRoutingService(api.Object, NullLogger.Instance, state); + + var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest( + WalkingProfile, expectedCatalogIdentity: "v1.catalog-a")); + + result.Outcome.Should().Be(HostedRoutingOutcome.Success); + } + + [Fact] + public async Task RequestRouteAsync_SelectedAuthorityChangeBeforePublication_DiscardsResponse() + { + var api = HostedRoutingApiMock.CreateSuccessful(WalkingProfile, "v1.catalog-a", "v1.selected-a"); + var state = HostedRoutingState.ForTest(selectedAuthorityIdentity: "v1.selected-b"); + var service = new HostedRoutingService(api.Object, NullLogger.Instance, state); + + var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest( + WalkingProfile, expectedCatalogIdentity: "v1.catalog-a")); + + result.Outcome.Should().Be(HostedRoutingOutcome.Stale); + result.Route.Should().BeNull(); + } + + private static HostedRoutingCatalog Catalog(params HostedRoutingProfile[] profiles) => + new("v1.catalog-a", "available", profiles); } From c511373d67e9a1e068b8ccee43003b14f6b24dc7 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 21:30:58 +0300 Subject: [PATCH 03/18] feat: add transient hosted routing owner --- .../Models/NavigationRoute.cs | 6 + .../Services/HostedRoutingApiClient.cs | 113 +++++++++++++++ .../Services/HostedRoutingModels.cs | 112 +++++++++++++++ .../Services/HostedRoutingService.cs | 134 ++++++++++++++++++ .../Services/HostedRoutingServiceTests.cs | 33 +++-- .../WayfarerMobile.Tests.csproj | 3 + 6 files changed, 393 insertions(+), 8 deletions(-) create mode 100644 src/WayfarerMobile/Services/HostedRoutingApiClient.cs create mode 100644 src/WayfarerMobile/Services/HostedRoutingModels.cs create mode 100644 src/WayfarerMobile/Services/HostedRoutingService.cs diff --git a/src/WayfarerMobile.Core/Models/NavigationRoute.cs b/src/WayfarerMobile.Core/Models/NavigationRoute.cs index 8c01f45..6b47b79 100644 --- a/src/WayfarerMobile.Core/Models/NavigationRoute.cs +++ b/src/WayfarerMobile.Core/Models/NavigationRoute.cs @@ -39,8 +39,14 @@ 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(); } +/// Contains one safe linked attribution displayed only with an active hosted route. +public sealed record HostedRouteAttribution(string Text, string Url); + /// /// A single turn-by-turn instruction in the navigation route. /// diff --git a/src/WayfarerMobile/Services/HostedRoutingApiClient.cs b/src/WayfarerMobile/Services/HostedRoutingApiClient.cs new file mode 100644 index 0000000..34edcb8 --- /dev/null +++ b/src/WayfarerMobile/Services/HostedRoutingApiClient.cs @@ -0,0 +1,113 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +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) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow + }; + 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); + var value = await ParseAsync(response, cancellationToken); + return value?.ToModel() ?? new("invalid-response", profileId, 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, new Uri(server, 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); + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + 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, Attribution, + DiscoveryCatalogIdentity, SelectedProfileAuthorityIdentity); + } + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + private sealed record RouteResponseDto(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 HostedRouteResponse ToModel() => new(Succeeded, Outcome, Geometry, DistanceMetres, DurationSeconds, + Instructions, GeneratedAt, TransportProfileId, MatchPoints, Attribution, StorageMode, + SelectedProfileAuthorityIdentity); + } +} diff --git a/src/WayfarerMobile/Services/HostedRoutingModels.cs b/src/WayfarerMobile/Services/HostedRoutingModels.cs new file mode 100644 index 0000000..baf1617 --- /dev/null +++ b/src/WayfarerMobile/Services/HostedRoutingModels.cs @@ -0,0 +1,112 @@ +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, + IReadOnlyList? Attribution, string? DiscoveryCatalogIdentity, + string? SelectedProfileAuthorityIdentity) +{ + public static HostedRoutingCapability Available(Guid profileId, string catalogIdentity, + string selectedAuthorityIdentity, IReadOnlyList attribution) => + new("available", profileId, 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, 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, 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); + } + + public static HostedRoutingProfile? Confirm(HostedRoutingProfile? choice, HostedRoutingCatalog currentCatalog) => + choice != null && currentCatalog.DiscoveryCatalogIdentity != null + && currentCatalog.Profiles.Any(item => item == choice) ? choice : null; + + 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, InvalidResponse, Stale, Cancelled } +public sealed record HostedRoutingResult(HostedRoutingOutcome Outcome, NavigationRoute? Route = null, + IReadOnlyList? Choices = null); + +public sealed record HostedRouteRequestContext(Guid? SavedTransportProfileId, string? ModeKey, string? Category, + HostedRouteCoordinate Origin, HostedRouteCoordinate Destination, IReadOnlyList Anchors, + string DestinationName, long Generation, string SessionAuthority, string NormalizedServer, + string TargetAssociation, string NavigationChoice, 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, + "session", "https://wayfarer.test", "place:test", "hosted", expectedCatalogIdentity); +} + +public sealed record HostedRoutingState(long Generation, string SessionAuthority, string NormalizedServer, + Guid? SelectedProfileId, string? SelectedAuthorityIdentity, string TargetAssociation, + string NavigationChoice, IReadOnlyList CanonicalCoordinates) +{ + public static HostedRoutingState ForTest(string? catalogIdentity = null, + string? selectedAuthorityIdentity = null) => new(1, "session", "https://wayfarer.test", + WalkingTestProfile, selectedAuthorityIdentity, "place:test", "hosted", + HostedRouteIdentity.Canonicalize([new(23, 37), new(23.01, 37.01)])); + + private static readonly Guid WalkingTestProfile = Guid.Parse("11111111-1111-1111-1111-111111111111"); +} + +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 checked((long)Math.Round(value * 100000d, MidpointRounding.AwayFromZero)); + } +} + +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..38a9c13 --- /dev/null +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.Logging; +using WayfarerMobile.Core.Models; + +namespace WayfarerMobile.Services; + +/// Owns transient authenticated hosted-route orchestration and final publication validation. +public sealed class HostedRoutingService +{ + private const int MaximumGeometry = 10000; + private const int MaximumInstructions = 1000; + private readonly IHostedRoutingApiClient api; + private readonly ILogger logger; + private readonly HostedRoutingState? currentState; + + public HostedRoutingService(IHostedRoutingApiClient api, ILogger logger, + HostedRoutingState? currentState = null) + { + this.api = api; + this.logger = logger; + this.currentState = currentState; + } + + public async Task RequestRouteAsync(HostedRouteRequestContext context, + HostedRoutingProfile? explicitChoice = null, CancellationToken cancellationToken = default) + { + try + { + var catalog = await api.DiscoverAsync(cancellationToken); + if (!AvailableCatalog(catalog)) return new(HostedRoutingOutcome.Unavailable); + if (context.ExpectedCatalogIdentity != null + && context.ExpectedCatalogIdentity != catalog.DiscoveryCatalogIdentity) + return new(HostedRoutingOutcome.Stale); + + var selection = explicitChoice == null + ? HostedProfileSelector.Select(context.SavedTransportProfileId, context.ModeKey, context.Category, catalog) + : new HostedProfileSelection(HostedProfileSelectionKind.Selected, + HostedProfileSelector.Confirm(explicitChoice, catalog), catalog.Profiles); + if (selection.Profile == null) + return new(HostedRoutingOutcome.RequiresChoice, Choices: selection.Choices); + + var capability = await api.GetCapabilityAsync(selection.Profile.TransportProfileId, + catalog.DiscoveryCatalogIdentity!, cancellationToken); + if (!ValidCapability(capability, selection.Profile.TransportProfileId, catalog.DiscoveryCatalogIdentity!)) + return new(capability.Outcome == "catalog-changed" ? HostedRoutingOutcome.Stale : HostedRoutingOutcome.Unavailable); + + var request = new HostedRouteRequest(selection.Profile.TransportProfileId, context.Origin, + context.Destination, context.Anchors, capability.SelectedProfileAuthorityIdentity!); + var response = await api.GetRouteAsync(request, cancellationToken); + if (!ValidResponse(response, request)) return new(HostedRoutingOutcome.InvalidResponse); + if (!Current(context, selection.Profile.TransportProfileId, capability.SelectedProfileAuthorityIdentity!)) + return new(HostedRoutingOutcome.Stale); + return new(HostedRoutingOutcome.Success, BuildRoute(response, context.DestinationName)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return new(HostedRoutingOutcome.Cancelled); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Hosted routing failed locally"); + return new(HostedRoutingOutcome.Unavailable); + } + } + + private bool Current(HostedRouteRequestContext context, Guid profileId, string authority) + { + if (currentState == null) return true; + var points = new[] { context.Origin }.Concat(context.Anchors).Append(context.Destination); + return currentState.Generation == context.Generation + && currentState.SessionAuthority == context.SessionAuthority + && currentState.NormalizedServer == context.NormalizedServer + && currentState.SelectedProfileId == profileId + && (currentState.SelectedAuthorityIdentity == null || currentState.SelectedAuthorityIdentity == authority) + && currentState.TargetAssociation == context.TargetAssociation + && currentState.NavigationChoice == context.NavigationChoice + && currentState.CanonicalCoordinates.SequenceEqual(HostedRouteIdentity.Canonicalize(points)); + } + + private static bool AvailableCatalog(HostedRoutingCatalog value) => value.Outcome == "available" + && ValidIdentity(value.DiscoveryCatalogIdentity) && value.Profiles.Count is > 0 and <= 100 + && value.Profiles.Select(item => item.TransportProfileId).Distinct().Count() == value.Profiles.Count + && value.Profiles.All(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 && ValidIdentity(value.SelectedProfileAuthorityIdentity) + && ValidAttribution(value.Attribution); + + private static bool ValidResponse(HostedRouteResponse value, HostedRouteRequest request) + { + if (!value.Succeeded || value.Outcome != "available" || value.TransportProfileId != request.TransportProfileId + || value.SelectedProfileAuthorityIdentity != request.SelectedProfileAuthorityIdentity + || value.GeneratedAt is null || 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 ValidIdentity(string? value) => value is { Length: >= 4 and <= 64 } && value.StartsWith("v1.", StringComparison.Ordinal); + 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/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs index a483e2a..f2c6db5 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs @@ -18,8 +18,8 @@ public void SelectProfile_UsesGuidThenOnlyAnUnambiguousTextualHint( bool savedGuidMatches, string modeKey, string category, HostedProfileSelectionKind expected) { var catalog = Catalog( - new(WalkingProfile, "Walking", "walk", "active"), - new(CyclingProfile, "Cycling", "bike", "active")); + new HostedRoutingProfile(WalkingProfile, "Walking", "walk", "active"), + new HostedRoutingProfile(CyclingProfile, "Cycling", "bike", "active")); var result = HostedProfileSelector.Select( savedGuidMatches ? WalkingProfile : null, modeKey, category, catalog); @@ -32,7 +32,7 @@ public void SelectProfile_UsesGuidThenOnlyAnUnambiguousTextualHint( [Fact] public void ConfirmChoice_RejectsCancelledAndStaleCatalogChoices() { - var original = Catalog(new(WalkingProfile, "Walking", "walk", "active")); + var original = Catalog(new HostedRoutingProfile(WalkingProfile, "Walking", "walk", "active")); var renamed = new HostedRoutingCatalog("v1.catalog-b", "available", [new(WalkingProfile, "On foot", "walk", "active")]); @@ -45,12 +45,12 @@ public void ConfirmChoice_RejectsCancelledAndStaleCatalogChoices() public async Task RequestRouteAsync_UsesCatalogForCapabilityAndSelectedAuthorityForRoute() { var api = new Mock(MockBehavior.Strict); - var catalog = Catalog(new(WalkingProfile, "Walking", "walk", "active")); + var catalog = Catalog(new HostedRoutingProfile(WalkingProfile, "Walking", "walk", "active")); api.Setup(client => client.DiscoverAsync(It.IsAny())).ReturnsAsync(catalog); api.Setup(client => client.GetCapabilityAsync( - WalkingProfile, catalog.DiscoveryCatalogIdentity, It.IsAny())) + WalkingProfile, catalog.DiscoveryCatalogIdentity!, It.IsAny())) .ReturnsAsync(HostedRoutingCapability.Available( - WalkingProfile, catalog.DiscoveryCatalogIdentity, "v1.selected-a", [])); + WalkingProfile, catalog.DiscoveryCatalogIdentity!, "v1.selected-a", Attribution())); api.Setup(client => client.GetRouteAsync( It.Is(request => request.TransportProfileId == WalkingProfile && request.SelectedProfileAuthorityIdentity == "v1.selected-a"), @@ -71,7 +71,7 @@ public async Task RequestRouteAsync_UsesCatalogForCapabilityAndSelectedAuthority [Fact] public async Task RequestRouteAsync_UnrelatedCatalogChangeAfterCapability_DoesNotInvalidateSelectedAuthority() { - var api = HostedRoutingApiMock.CreateSuccessful(WalkingProfile, "v1.catalog-a", "v1.selected-a"); + var api = SuccessfulApi(WalkingProfile, "v1.catalog-a", "v1.selected-a"); var state = HostedRoutingState.ForTest(catalogIdentity: "v1.catalog-b", selectedAuthorityIdentity: "v1.selected-a"); var service = new HostedRoutingService(api.Object, NullLogger.Instance, state); @@ -84,7 +84,7 @@ public async Task RequestRouteAsync_UnrelatedCatalogChangeAfterCapability_DoesNo [Fact] public async Task RequestRouteAsync_SelectedAuthorityChangeBeforePublication_DiscardsResponse() { - var api = HostedRoutingApiMock.CreateSuccessful(WalkingProfile, "v1.catalog-a", "v1.selected-a"); + var api = SuccessfulApi(WalkingProfile, "v1.catalog-a", "v1.selected-a"); var state = HostedRoutingState.ForTest(selectedAuthorityIdentity: "v1.selected-b"); var service = new HostedRoutingService(api.Object, NullLogger.Instance, state); @@ -97,4 +97,21 @@ public async Task RequestRouteAsync_SelectedAuthorityChangeBeforePublication_Dis private static HostedRoutingCatalog Catalog(params HostedRoutingProfile[] profiles) => new("v1.catalog-a", "available", profiles); + + private static Mock SuccessfulApi(Guid profileId, string catalogIdentity, + string authorityIdentity) + { + var api = new Mock(); + api.Setup(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(new HostedRoutingCatalog(catalogIdentity, "available", + [new(profileId, "Walking", "walk", "active")])); + api.Setup(client => client.GetCapabilityAsync(profileId, catalogIdentity, It.IsAny())) + .ReturnsAsync(HostedRoutingCapability.Available(profileId, catalogIdentity, authorityIdentity, Attribution())); + api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(HostedRouteResponse.ValidForTest(profileId, authorityIdentity)); + return api; + } + + private static IReadOnlyList Attribution() => + [new("Powered by Wayfarer test", "https://example.test")]; } diff --git a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj index 8a7171a..901c039 100644 --- a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj +++ b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj @@ -76,6 +76,9 @@ + + + From a53b1e0f86f5e67bc99c2552b1b474f434c6eb24 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 21:42:11 +0300 Subject: [PATCH 04/18] feat: integrate transient Wayfarer routing --- CHANGELOG.md | 8 ++ docs/12-Services.md | 16 ++- docs/13-API.md | 11 +- .../Interfaces/IDialogService.cs | 3 + .../Interfaces/ITripNavigationService.cs | 1 + src/WayfarerMobile/MauiProgram.cs | 2 + src/WayfarerMobile/Services/ApiClient.cs | 24 ++-- src/WayfarerMobile/Services/DialogService.cs | 7 ++ .../Services/HostedRoutingApiClient.cs | 2 +- .../Services/HostedRoutingService.cs | 65 ++++++++-- .../Services/HostedSegmentProfileIdentity.cs | 24 ++++ .../Services/TripNavigationService.cs | 1 + .../ViewModels/ContextMenuViewModel.cs | 1 + .../ViewModels/MemberDetailsViewModel.cs | 1 + .../NavigationCoordinatorViewModel.cs | 117 +++++++++++++++++- .../ViewModels/NavigationHudViewModel.cs | 13 ++ .../Views/Controls/NavigationHud.xaml | 14 +++ .../Controls/NavigationMethodPicker.xaml.cs | 13 +- .../Services/HostedRoutingApiClientTests.cs | 76 ++++++++++++ .../Services/HostedRoutingServiceTests.cs | 77 ++++++++++++ 20 files changed, 447 insertions(+), 29 deletions(-) create mode 100644 src/WayfarerMobile/Services/HostedSegmentProfileIdentity.cs create mode 100644 tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 47817cc..1a839c7 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 + - Displays server-returned attribution only for the active hosted 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/12-Services.md b/docs/12-Services.md index 3d91c5a..ad9d496 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,12 @@ 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 HUD displays the linked attribution returned by Wayfarer and clears it on 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. ### Navigation State diff --git a/docs/13-API.md b/docs/13-API.md index cbf7ffa..c7bedae 100644 --- a/docs/13-API.md +++ b/docs/13-API.md @@ -477,7 +477,16 @@ 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. + +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. ## JSON Serialization 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/ITripNavigationService.cs b/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs index 0c212d6..37aaec4 100644 --- a/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs +++ b/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs @@ -48,6 +48,7 @@ public interface ITripNavigationService /// void StopNavigation(); + /// /// Loads a trip for navigation, building the routing graph. /// 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 index 34edcb8..9b8c21c 100644 --- a/src/WayfarerMobile/Services/HostedRoutingApiClient.cs +++ b/src/WayfarerMobile/Services/HostedRoutingApiClient.cs @@ -59,7 +59,7 @@ private async Task SendAsync(HttpMethod method, string endp { 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, new Uri(server, endpoint)); + 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); diff --git a/src/WayfarerMobile/Services/HostedRoutingService.cs b/src/WayfarerMobile/Services/HostedRoutingService.cs index 38a9c13..e70bf48 100644 --- a/src/WayfarerMobile/Services/HostedRoutingService.cs +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -11,6 +11,9 @@ public sealed class HostedRoutingService private readonly IHostedRoutingApiClient api; private readonly ILogger logger; private readonly HostedRoutingState? currentState; + private readonly object stateLock = new(); + private HostedRoutingState? activeState; + public bool IsLoading { get; private set; } public HostedRoutingService(IHostedRoutingApiClient api, ILogger logger, HostedRoutingState? currentState = null) @@ -23,6 +26,7 @@ public HostedRoutingService(IHostedRoutingApiClient api, ILogger RequestRouteAsync(HostedRouteRequestContext context, HostedRoutingProfile? explicitChoice = null, CancellationToken cancellationToken = default) { + Begin(context); try { var catalog = await api.DiscoverAsync(cancellationToken); @@ -37,11 +41,14 @@ public async Task RequestRouteAsync(HostedRouteRequestConte HostedProfileSelector.Confirm(explicitChoice, catalog), catalog.Profiles); if (selection.Profile == null) return new(HostedRoutingOutcome.RequiresChoice, Choices: selection.Choices); + UpdateSelection(context.Generation, selection.Profile.TransportProfileId, null); var capability = await api.GetCapabilityAsync(selection.Profile.TransportProfileId, catalog.DiscoveryCatalogIdentity!, cancellationToken); if (!ValidCapability(capability, selection.Profile.TransportProfileId, catalog.DiscoveryCatalogIdentity!)) return new(capability.Outcome == "catalog-changed" ? HostedRoutingOutcome.Stale : HostedRoutingOutcome.Unavailable); + UpdateSelection(context.Generation, selection.Profile.TransportProfileId, + capability.SelectedProfileAuthorityIdentity); var request = new HostedRouteRequest(selection.Profile.TransportProfileId, context.Origin, context.Destination, context.Anchors, capability.SelectedProfileAuthorityIdentity!); @@ -60,20 +67,60 @@ public async Task RequestRouteAsync(HostedRouteRequestConte logger.LogWarning(ex, "Hosted routing failed locally"); return new(HostedRoutingOutcome.Unavailable); } + finally + { + lock (stateLock) + if ((currentState ?? activeState)?.Generation == context.Generation) IsLoading = false; + } + } + + public void SelectDirect(long generation) + { + lock (stateLock) + { + activeState = activeState is { } state + ? state with { Generation = generation, NavigationChoice = "direct", SelectedProfileId = null, + SelectedAuthorityIdentity = null } + : null; + IsLoading = false; + } } private bool Current(HostedRouteRequestContext context, Guid profileId, string authority) { - if (currentState == null) return true; + HostedRoutingState? state; + lock (stateLock) state = currentState ?? activeState; + if (state == null) return true; + var points = new[] { context.Origin }.Concat(context.Anchors).Append(context.Destination); + return state.Generation == context.Generation + && state.SessionAuthority == context.SessionAuthority + && state.NormalizedServer == context.NormalizedServer + && state.SelectedProfileId == profileId + && state.SelectedAuthorityIdentity == authority + && state.TargetAssociation == context.TargetAssociation + && state.NavigationChoice == context.NavigationChoice + && state.CanonicalCoordinates.SequenceEqual(HostedRouteIdentity.Canonicalize(points)); + } + + private void Begin(HostedRouteRequestContext context) + { + if (currentState != null) return; var points = new[] { context.Origin }.Concat(context.Anchors).Append(context.Destination); - return currentState.Generation == context.Generation - && currentState.SessionAuthority == context.SessionAuthority - && currentState.NormalizedServer == context.NormalizedServer - && currentState.SelectedProfileId == profileId - && (currentState.SelectedAuthorityIdentity == null || currentState.SelectedAuthorityIdentity == authority) - && currentState.TargetAssociation == context.TargetAssociation - && currentState.NavigationChoice == context.NavigationChoice - && currentState.CanonicalCoordinates.SequenceEqual(HostedRouteIdentity.Canonicalize(points)); + lock (stateLock) + { + activeState = new(context.Generation, context.SessionAuthority, context.NormalizedServer, null, null, + context.TargetAssociation, context.NavigationChoice, HostedRouteIdentity.Canonicalize(points)); + IsLoading = true; + } + } + + private void UpdateSelection(long generation, Guid profileId, string? authority) + { + if (currentState != null) return; + lock (stateLock) + if (activeState?.Generation == generation) + activeState = activeState with { SelectedProfileId = profileId, + SelectedAuthorityIdentity = authority ?? activeState.SelectedAuthorityIdentity }; } private static bool AvailableCatalog(HostedRoutingCatalog value) => value.Outcome == "available" 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/TripNavigationService.cs b/src/WayfarerMobile/Services/TripNavigationService.cs index fd4c465..2660f50 100644 --- a/src/WayfarerMobile/Services/TripNavigationService.cs +++ b/src/WayfarerMobile/Services/TripNavigationService.cs @@ -136,6 +136,7 @@ public void StopNavigation() _lastAnnouncementTime = DateTime.MinValue; } + /// /// Calculates a route to a specific place using saved Segment geometry or Direct guidance. /// 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..ce65d5a 100644 --- a/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs +++ b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs @@ -313,6 +313,7 @@ await OpenExternalMapsAsync( var travelProfile = navMethod switch { + NavigationMethod.Direct => "direct", NavigationMethod.Walk => "foot", NavigationMethod.Drive => "car", NavigationMethod.Bike => "bike", diff --git a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs index 3025a81..7709738 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -1,9 +1,12 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; +using System.Security.Cryptography; +using System.Text; using WayfarerMobile.Core.Enums; using WayfarerMobile.Core.Interfaces; using WayfarerMobile.Core.Models; +using WayfarerMobile.Services; namespace WayfarerMobile.ViewModels; @@ -20,6 +23,12 @@ 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; // Callbacks to parent ViewModel private INavigationCallbacks? _callbacks; @@ -72,11 +81,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 @@ -125,6 +142,16 @@ public async Task StartNavigationToPlaceAsync(string placeId) currentLocation.Longitude, placeId); + if (route?.IsDirectRoute == true && Guid.TryParse(placeId, out var destinationId)) + { + var segment = FindCurrentSegment(destinationId, currentLocation.Latitude, currentLocation.Longitude); + var anchors = ResolveAnchors(segment); + route = await TryHostedAsync(route, currentLocation.Latitude, currentLocation.Longitude, + route.Waypoints[^1].Latitude, route.Waypoints[^1].Longitude, route.DestinationName, + segment?.TransportMode ?? "walk", HostedSegmentProfileIdentity.Get(segment), anchors, + $"trip-place:{placeId}"); + } + if (route != null) { // Track navigation destination for visit notification conflict detection @@ -182,6 +209,7 @@ public async Task StartNavigationToNextAsync() [RelayCommand] public void StopNavigation() { + CancelHostedRouting(); _tripNavigationService.StopNavigation(); // Notify visit notification service that navigation ended @@ -251,11 +279,97 @@ 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, [], "ad-hoc-coordinates"); + } + + private async Task TryHostedAsync(NavigationRoute direct, double fromLat, double fromLon, + double toLat, double toLon, string destinationName, string profile, Guid? savedProfileId, + IReadOnlyList anchors, string targetAssociation) + { + 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, savedProfileId, anchors, targetAssociation); + var result = await _hostedRouting.RequestRouteAsync(context, cancellationToken: _hostedRoutingCancellation.Token); + if (result.Outcome == HostedRoutingOutcome.RequiresChoice && result.Choices is { Count: > 0 }) + { + 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; + } + result = await _hostedRouting.RequestRouteAsync(context, result.Choices[index], _hostedRoutingCancellation.Token); + } + if (result.Outcome != HostedRoutingOutcome.Success || result.Route == null) return direct; + CopyRoute(result.Route, direct); + return direct; + } + + private HostedRouteRequestContext CreateHostedContext(double fromLat, double fromLon, double toLat, + double toLon, string destinationName, string profile, long generation, Guid? savedProfileId, + IReadOnlyList anchors, string targetAssociation) + { + var mode = profile switch { "foot" => "walk", "car" => "drive", "bike" => "bicycle", _ => profile }; + var server = Uri.TryCreate(_settings.ServerUrl, UriKind.Absolute, out var uri) + ? uri.GetLeftPart(UriPartial.Authority).TrimEnd('/').ToLowerInvariant() : string.Empty; + var token = _settings.ApiToken ?? string.Empty; + var sessionAuthority = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + return new(savedProfileId, mode, mode, new(fromLon, fromLat), new(toLon, toLat), anchors, destinationName, + generation, sessionAuthority, server, targetAssociation, "hosted"); + } + + private TripSegment? FindCurrentSegment(Guid destinationId, double latitude, double longitude) => + _tripState.LoadedTrip?.Segments.Where(item => item.DestinationId == destinationId) + .Select(item => (Segment: item, Origin: _tripState.LoadedTrip.AllPlaces + .SingleOrDefault(place => place.Id == item.OriginId))) + .Where(item => item.Origin != null) + .OrderBy(item => Core.Algorithms.GeoMath.CalculateDistance(latitude, longitude, + item.Origin!.Latitude, item.Origin.Longitude)) + .Select(item => item.Segment).FirstOrDefault(); + + private IReadOnlyList ResolveAnchors(TripSegment? segment) + { + if (segment?.Waypoints.Count is not (> 0 and <= 3) || _tripState.LoadedTrip == null) return []; + var places = _tripState.LoadedTrip.AllPlaces.ToDictionary(item => item.Id); + var result = new List(segment.Waypoints.Count); + foreach (var waypoint in segment.Waypoints.OrderBy(item => item.Position)) + { + if (!places.TryGetValue(waypoint.PlaceId, out var place)) return []; + result.Add(new(place.Longitude, place.Latitude)); + } + return result; + } + + private static void CopyRoute(NavigationRoute source, NavigationRoute target) + { + 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; + } + + private void CancelHostedRouting(bool incrementGeneration = true) + { + if (incrementGeneration) _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); + _hostedRoutingCancellation?.Cancel(); + _hostedRoutingCancellation?.Dispose(); + _hostedRoutingCancellation = null; } /// @@ -301,6 +415,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 @@ + + + + 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 2660f50..fd4c465 100644 --- a/src/WayfarerMobile/Services/TripNavigationService.cs +++ b/src/WayfarerMobile/Services/TripNavigationService.cs @@ -136,7 +136,6 @@ public void StopNavigation() _lastAnnouncementTime = DateTime.MinValue; } - /// /// Calculates a route to a specific place using saved Segment geometry or Direct guidance. /// From fa733817744f4b4f4455a719b3e99898ede8bbac Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 21:43:40 +0300 Subject: [PATCH 06/18] test: prove transient segment profile identity --- .../Unit/Services/HostedRoutingApiClientTests.cs | 15 +++++++++++++++ .../WayfarerMobile.Tests.csproj | 1 + 2 files changed, 16 insertions(+) diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs index 1b54626..1daa6e2 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs @@ -1,5 +1,7 @@ using System.Net; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.Logging.Abstractions; using Moq; using WayfarerMobile.Core.Interfaces; @@ -9,6 +11,19 @@ namespace WayfarerMobile.Tests.Unit.Services; public sealed class HostedRoutingApiClientTests { + [Fact] + public void TripJson_CapturesCurrentProfileGuidOnlyInTransientObjectState() + { + var profileId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var resolver = new DefaultJsonTypeInfoResolver(); + resolver.Modifiers.Add(HostedSegmentProfileIdentity.Configure); + var segment = JsonSerializer.Deserialize( + $$"""{"id":"22222222-2222-2222-2222-222222222222","transportProfileId":"{{profileId}}"}""", + new JsonSerializerOptions { TypeInfoResolver = resolver }); + + HostedSegmentProfileIdentity.Get(segment).Should().Be(profileId); + } + [Fact] public async Task ControlledFlow_UsesOnlyAuthenticatedWayfarerContractAndBothIdentities() { diff --git a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj index 901c039..7053c9d 100644 --- a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj +++ b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj @@ -79,6 +79,7 @@ + From e9f8970536cd983cfb1dc1a3e8b822b21a70e8ce Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 21:44:00 +0300 Subject: [PATCH 07/18] fix: canonicalize hosted coordinates deterministically --- src/WayfarerMobile/Services/HostedRoutingModels.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WayfarerMobile/Services/HostedRoutingModels.cs b/src/WayfarerMobile/Services/HostedRoutingModels.cs index baf1617..14230e7 100644 --- a/src/WayfarerMobile/Services/HostedRoutingModels.cs +++ b/src/WayfarerMobile/Services/HostedRoutingModels.cs @@ -99,7 +99,7 @@ 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 checked((long)Math.Round(value * 100000d, MidpointRounding.AwayFromZero)); + return decimal.ToInt64(decimal.Round((decimal)value * 100000m, 0, MidpointRounding.AwayFromZero)); } } From 6db776f3a8f4b26951e5c8dc0c2d22da2440f161 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 22:24:18 +0300 Subject: [PATCH 08/18] WIP: prove final hosted publication defects (checkpoint; tests failing) --- .../Services/HostedRoutePublicationTests.cs | 74 +++++++++++++++++++ .../Services/HostedRoutingApiClientTests.cs | 20 +++++ .../Services/HostedRoutingServiceTests.cs | 16 +++- .../HostedRoutingTriggerIntegrationTests.cs | 29 ++++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs create mode 100644 tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs new file mode 100644 index 0000000..6ae006f --- /dev/null +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs @@ -0,0 +1,74 @@ +using WayfarerMobile.Core.Models; +using WayfarerMobile.Services; + +namespace WayfarerMobile.Tests.Unit.Services; + +public sealed class HostedRoutePublicationTests +{ + [Theory] + [InlineData(2, "hosted")] + [InlineData(1, "direct")] + public void CandidateCannotOverwriteNewerOrDirectRoute(long liveGeneration, string liveChoice) + { + var direct = DirectRoute(); + var candidate = Candidate(); + var live = candidate.Context with { Generation = liveGeneration, NavigationChoice = liveChoice }; + + HostedRoutePublication.TryPublish(candidate, live, direct).Should().BeFalse(); + direct.IsDirectRoute.Should().BeTrue(); + direct.Attribution.Should().BeEmpty(); + } + + [Theory] + [InlineData("different-session", "https://wayfarer.test", "place:test")] + [InlineData("session", "https://other.test", "place:test")] + [InlineData("session", "https://wayfarer.test", "member:other")] + public void CandidateCannotPublishAfterLiveAuthorityDriftsWithoutAnotherHostedRequest( + string session, string server, string target) + { + var direct = DirectRoute(); + var candidate = Candidate(); + var live = candidate.Context with + { + SessionAuthority = session, + NormalizedServer = server, + TargetAssociation = target + }; + + HostedRoutePublication.TryPublish(candidate, live, direct).Should().BeFalse(); + direct.IsDirectRoute.Should().BeTrue(); + } + + [Fact] + public void CandidatePublishesOnlyWhenAllLiveAuthorityStillMatches() + { + var direct = DirectRoute(); + var candidate = Candidate(); + + HostedRoutePublication.TryPublish(candidate, candidate.Context, direct).Should().BeTrue(); + direct.IsDirectRoute.Should().BeFalse(); + direct.Attribution.Should().ContainSingle(); + } + + private static HostedRouteCandidate Candidate() + { + var context = HostedRouteRequestContext.ForTest( + Guid.Parse("11111111-1111-1111-1111-111111111111")); + return new HostedRouteCandidate(RoutedRoute(), context, + Guid.Parse("11111111-1111-1111-1111-111111111111"), + "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + } + + private static NavigationRoute DirectRoute() => new() + { + IsDirectRoute = true, + Waypoints = [new() { Longitude = 23, Latitude = 37 }, new() { Longitude = 23.01, Latitude = 37.01 }] + }; + + private static NavigationRoute RoutedRoute() => new() + { + IsDirectRoute = false, + Waypoints = [new() { Longitude = 23, Latitude = 37 }, new() { Longitude = 23.02, Latitude = 37.02 }], + Attribution = [new("Powered by test", "https://example.test")] + }; +} diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs index 1daa6e2..464b73f 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs @@ -67,6 +67,26 @@ public async Task OldBackend404_IsBoundedRoutingUnavailable() catalog.Profiles.Should().BeEmpty(); } + [Fact] + public async Task Discovery_IgnoresUnknownAdditiveResponseMember() + { + const string identity = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + var profileId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var json = JsonSerializer.Serialize(new + { + outcome = "available", + discoveryCatalogIdentity = identity, + profiles = new[] { new { transportProfileId = profileId, displayName = "Walking", modeKey = "walk", category = "active" } }, + futureMetadata = new { version = 2 } + }); + var client = Create(new RecordingHandler(_ => Task.FromResult(Json(HttpStatusCode.OK, json)))); + + var catalog = await client.DiscoverAsync(default); + + catalog.Outcome.Should().Be("available"); + catalog.Profiles.Should().ContainSingle(); + } + private static HostedRoutingApiClient Create(HttpMessageHandler handler) { var settings = new Mock(); diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs index ef4e3e1..02e62e4 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs @@ -30,7 +30,7 @@ public void SelectProfile_UsesGuidThenOnlyAnUnambiguousTextualHint( } [Fact] - public void ConfirmChoice_RejectsCancelledAndStaleCatalogChoices() + public void ConfirmChoice_AcceptsSameGuidAndRefreshesRenamedMetadata() { var original = Catalog(new HostedRoutingProfile(WalkingProfile, "Walking", "walk", "active")); var renamed = new HostedRoutingCatalog("v1.catalog-b", "available", @@ -38,7 +38,19 @@ public void ConfirmChoice_RejectsCancelledAndStaleCatalogChoices() HostedProfileSelector.Confirm(null, original).Should().BeNull(); HostedProfileSelector.Confirm(new(WalkingProfile, "Walking", "walk", "active"), renamed) - .Should().BeNull(); + .Should().Be(renamed.Profiles[0]); + } + + [Theory] + [InlineData("v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", true)] + [InlineData("v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", false)] + [InlineData(" v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", false)] + [InlineData("v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", false)] + [InlineData("v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB", false)] + [InlineData("v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAé", false)] + public void OpaqueIdentity_RequiresCanonicalSha256Base64UrlFraming(string value, bool expected) + { + HostedOpaqueIdentity.IsValid(value).Should().Be(expected); } [Fact] diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs new file mode 100644 index 0000000..d537db1 --- /dev/null +++ b/tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs @@ -0,0 +1,29 @@ +namespace WayfarerMobile.Tests.Unit.ViewModels; + +public sealed class HostedRoutingTriggerIntegrationTests +{ + [Fact] + public void MemberDirectPathUsesSharedCoordinatorInsteadOfTripNavigationService() + { + var source = ReadSource("MemberDetailsViewModel.cs"); + + source.Should().Contain("CalculateHostedRouteToCoordinatesAsync") + .And.NotContain("_tripNavigationService.CalculateRouteToCoordinatesAsync"); + } + + [Fact] + public void NextPlaceDirectPathUsesSharedHostedOwner() + { + var source = ReadSource("NavigationCoordinatorViewModel.cs"); + var method = source[source.IndexOf("StartNavigationToNextAsync", StringComparison.Ordinal)..]; + method = method[..method.IndexOf("/// ", StringComparison.Ordinal)]; + + method.Should().Contain("TryHostedAsync"); + } + + private static string ReadSource(string fileName) + { + var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + return File.ReadAllText(Path.Combine(root, "src", "WayfarerMobile", "ViewModels", fileName)); + } +} From bbdf99ea7cf7535894ddfbda68e64e1fb917777e Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 22:32:50 +0300 Subject: [PATCH 09/18] fix: fence hosted route publication --- docs/03-Features.md | 14 +- docs/07-Troubleshooting.md | 4 + docs/11-Architecture.md | 5 +- docs/12-Services.md | 5 + docs/15-Security.md | 8 ++ .../Services/HostedRoutingApiClient.cs | 33 +++-- .../Services/HostedRoutingModels.cs | 98 +++++++++++--- .../Services/HostedRoutingService.cs | 92 ++++++------- .../ViewModels/MemberDetailsViewModel.cs | 13 +- .../NavigationCoordinatorViewModel.cs | 90 ++++++++++--- .../Services/HostedRoutePublicationTests.cs | 13 +- .../Services/HostedRoutingApiClientTests.cs | 84 +++++++++++- .../Services/HostedRoutingServiceTests.cs | 126 ++++++++++++++---- .../HostedRoutingTriggerIntegrationTests.cs | 1 + 14 files changed, 428 insertions(+), 158 deletions(-) diff --git a/docs/03-Features.md b/docs/03-Features.md index e639aca..df93488 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,7 @@ 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 the chosen profile are session-only and are not retained for offline use; #261 owns offline retention. ### External Maps Integration diff --git a/docs/07-Troubleshooting.md b/docs/07-Troubleshooting.md index cc5a62d..13c054c 100644 --- a/docs/07-Troubleshooting.md +++ b/docs/07-Troubleshooting.md @@ -257,6 +257,10 @@ 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 a response is stale or invalid. 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 is 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..7558476 100644 --- a/docs/11-Architecture.md +++ b/docs/11-Architecture.md @@ -428,9 +428,10 @@ 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 +2. **Authenticated Wayfarer route**: Fresh provider-neutral, session-only geometry +3. **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. +Mobile contacts only its configured Wayfarer server. The coordinator validates a returned candidate against live session, server, profile, authority, target, endpoints, choice, and generation state in the same synchronous callback that installs it. Provider credentials and provider-specific endpoints remain server-side. ### Navigation Graph diff --git a/docs/12-Services.md b/docs/12-Services.md index ad9d496..8505ba3 100644 --- a/docs/12-Services.md +++ b/docs/12-Services.md @@ -558,6 +558,11 @@ unavailability remain routing-local and retain Direct guidance without affecting 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. +`TransportProfileId` is the Segment's current planning profile identity. A returned route's selected profile and +authority metadata are immutable provenance for that transient result; they do not rewrite the Segment and are not a +durable current-profile setting. The coordinator treats service output as a candidate and performs its final live-state +comparison immediately beside the synchronous route copy. + ### Navigation State ```csharp diff --git a/docs/15-Security.md b/docs/15-Security.md index afdfc70..cb44ee1 100644 --- a/docs/15-Security.md +++ b/docs/15-Security.md @@ -98,6 +98,14 @@ 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 keeps the returned route and provider-neutral authority metadata only for the current session; #261 owns any +future offline retention policy. + ## Secure Storage ### MAUI SecureStorage diff --git a/src/WayfarerMobile/Services/HostedRoutingApiClient.cs b/src/WayfarerMobile/Services/HostedRoutingApiClient.cs index 9b8c21c..8c4ec8b 100644 --- a/src/WayfarerMobile/Services/HostedRoutingApiClient.cs +++ b/src/WayfarerMobile/Services/HostedRoutingApiClient.cs @@ -2,7 +2,7 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; -using System.Text.Json.Serialization; +using System.Globalization; using WayfarerMobile.Core.Interfaces; using WayfarerMobile.Core.Models; @@ -12,10 +12,7 @@ namespace WayfarerMobile.Services; public sealed class HostedRoutingApiClient : IHostedRoutingApiClient { private const int MaximumResponseBytes = 2 * 1024 * 1024; - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow - }; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly IHttpClientFactory httpClientFactory; private readonly ISettingsService settings; @@ -40,9 +37,9 @@ public async Task GetCapabilityAsync(Guid profileId, 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); + 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); + return value?.ToModel() ?? new("invalid-response", profileId, null, null, null, null, null, null, null); } public async Task GetRouteAsync(HostedRouteRequest request, @@ -86,28 +83,36 @@ private async Task SendAsync(HttpMethod method, string endp } private static HostedRouteResponse Failure(string outcome) => - new(false, outcome, null, null, null, null, null, null, null, null, null, null); + new(false, outcome, null, null, null, null, null, null, null, null, null, null, null, null, null); - [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] 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, Attribution, - DiscoveryCatalogIdentity, SelectedProfileAuthorityIdentity); + public HostedRoutingCapability ToModel() => new(Outcome, TransportProfileId, Provider, + ProviderConfigurationId, MappingIdentity, StorageMode, Attribution, DiscoveryCatalogIdentity, + SelectedProfileAuthorityIdentity); } - [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] private sealed record RouteResponseDto(bool Succeeded, string Outcome, IReadOnlyList? Geometry, double? DistanceMetres, double? DurationSeconds, IReadOnlyList? Instructions, - DateTimeOffset? GeneratedAt, string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity, + 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, GeneratedAt, TransportProfileId, MatchPoints, Attribution, StorageMode, + 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 index 14230e7..04409b6 100644 --- a/src/WayfarerMobile/Services/HostedRoutingModels.cs +++ b/src/WayfarerMobile/Services/HostedRoutingModels.cs @@ -9,12 +9,16 @@ public sealed record HostedRouteInstruction(string Text, string Type, int FromIn 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) => - new("available", profileId, attribution, catalogIdentity, selectedAuthorityIdentity); + 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, @@ -23,13 +27,15 @@ public sealed record HostedRouteRequest(Guid TransportProfileId, HostedRouteCoor public sealed record HostedRouteResponse(bool Succeeded, string Outcome, IReadOnlyList? Geometry, double? DistanceMetres, double? DurationSeconds, IReadOnlyList? Instructions, - DateTimeOffset? GeneratedAt, Guid? TransportProfileId, IReadOnlyList? MatchPoints, + 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, profileId, + [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); } @@ -57,7 +63,8 @@ public static HostedProfileSelection Select(Guid? savedProfileId, string? modeKe public static HostedRoutingProfile? Confirm(HostedRoutingProfile? choice, HostedRoutingCatalog currentCatalog) => choice != null && currentCatalog.DiscoveryCatalogIdentity != null - && currentCatalog.Profiles.Any(item => item == choice) ? choice : null; + ? currentCatalog.Profiles.SingleOrDefault(item => item.TransportProfileId == choice.TransportProfileId) + : null; private static bool TextMatches(HostedRoutingProfile item, string? modeKey, string? category) => (!string.IsNullOrWhiteSpace(modeKey) && string.Equals(item.ModeKey, modeKey, StringComparison.OrdinalIgnoreCase)) @@ -66,30 +73,25 @@ private static bool TextMatches(HostedRoutingProfile item, string? modeKey, stri public enum HostedRoutingOutcome { Success, Unavailable, RequiresChoice, InvalidResponse, Stale, Cancelled } public sealed record HostedRoutingResult(HostedRoutingOutcome Outcome, NavigationRoute? Route = null, - IReadOnlyList? Choices = null); + IReadOnlyList? Choices = null, HostedRouteCandidate? Candidate = 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); public sealed record HostedRouteRequestContext(Guid? SavedTransportProfileId, string? ModeKey, string? Category, HostedRouteCoordinate Origin, HostedRouteCoordinate Destination, IReadOnlyList Anchors, string DestinationName, long Generation, string SessionAuthority, string NormalizedServer, - string TargetAssociation, string NavigationChoice, string? ExpectedCatalogIdentity = null) + string TargetAssociation, string NavigationChoice, string? ExpectedCatalogIdentity = null, + Guid? SelectedTransportProfileId = null, string? SelectedProfileAuthorityIdentity = null) { public static HostedRouteRequestContext ForTest(Guid profileId, string? expectedCatalogIdentity = null) => new( profileId, "walk", "active", new(23, 37), new(23.01, 37.01), [], "Target", 1, "session", "https://wayfarer.test", "place:test", "hosted", expectedCatalogIdentity); } -public sealed record HostedRoutingState(long Generation, string SessionAuthority, string NormalizedServer, - Guid? SelectedProfileId, string? SelectedAuthorityIdentity, string TargetAssociation, - string NavigationChoice, IReadOnlyList CanonicalCoordinates) -{ - public static HostedRoutingState ForTest(string? catalogIdentity = null, - string? selectedAuthorityIdentity = null) => new(1, "session", "https://wayfarer.test", - WalkingTestProfile, selectedAuthorityIdentity, "place:test", "hosted", - HostedRouteIdentity.Canonicalize([new(23, 37), new(23.01, 37.01)])); - - private static readonly Guid WalkingTestProfile = Guid.Parse("11111111-1111-1111-1111-111111111111"); -} - public static class HostedRouteIdentity { public static IReadOnlyList Canonicalize(IEnumerable points) => points @@ -103,6 +105,64 @@ private static long Scale(double value, double bound) } } +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, HostedRouteRequestContext live, + NavigationRoute target) + { + if (!Current(candidate, live)) return false; + Copy(candidate.Route, target); + return true; + } + + public static bool Current(HostedRouteCandidate candidate, HostedRouteRequestContext live) + { + var expected = candidate.Context; + return live.Generation == expected.Generation + && live.SessionAuthority == expected.SessionAuthority + && live.NormalizedServer == expected.NormalizedServer + && live.TargetAssociation == expected.TargetAssociation + && live.NavigationChoice == expected.NavigationChoice + && live.NavigationChoice == "hosted" + && live.SavedTransportProfileId == expected.SavedTransportProfileId + && live.SelectedTransportProfileId == candidate.SelectedProfileId + && live.SelectedProfileAuthorityIdentity == candidate.SelectedProfileAuthorityIdentity + && HostedRouteIdentity.Canonicalize(Points(live)).SequenceEqual(HostedRouteIdentity.Canonicalize(Points(expected))); + } + + private static IEnumerable Points(HostedRouteRequestContext context) => + new[] { context.Origin }.Concat(context.Anchors).Append(context.Destination); + + private static void Copy(NavigationRoute source, NavigationRoute target) + { + 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; + } +} + public interface IHostedRoutingApiClient { Task DiscoverAsync(CancellationToken cancellationToken); diff --git a/src/WayfarerMobile/Services/HostedRoutingService.cs b/src/WayfarerMobile/Services/HostedRoutingService.cs index e70bf48..76d224c 100644 --- a/src/WayfarerMobile/Services/HostedRoutingService.cs +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -3,30 +3,27 @@ namespace WayfarerMobile.Services; -/// Owns transient authenticated hosted-route orchestration and final publication validation. +/// 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 HostedRoutingState? currentState; private readonly object stateLock = new(); - private HostedRoutingState? activeState; + private long activeGeneration; public bool IsLoading { get; private set; } - public HostedRoutingService(IHostedRoutingApiClient api, ILogger logger, - HostedRoutingState? currentState = null) + public HostedRoutingService(IHostedRoutingApiClient api, ILogger logger) { this.api = api; this.logger = logger; - this.currentState = currentState; } public async Task RequestRouteAsync(HostedRouteRequestContext context, HostedRoutingProfile? explicitChoice = null, CancellationToken cancellationToken = default) { - Begin(context); + if (!Begin(context)) return new(HostedRoutingOutcome.Stale); try { var catalog = await api.DiscoverAsync(cancellationToken); @@ -41,36 +38,37 @@ public async Task RequestRouteAsync(HostedRouteRequestConte HostedProfileSelector.Confirm(explicitChoice, catalog), catalog.Profiles); if (selection.Profile == null) return new(HostedRoutingOutcome.RequiresChoice, Choices: selection.Choices); - UpdateSelection(context.Generation, selection.Profile.TransportProfileId, null); - var capability = await api.GetCapabilityAsync(selection.Profile.TransportProfileId, catalog.DiscoveryCatalogIdentity!, cancellationToken); if (!ValidCapability(capability, selection.Profile.TransportProfileId, catalog.DiscoveryCatalogIdentity!)) return new(capability.Outcome == "catalog-changed" ? HostedRoutingOutcome.Stale : HostedRoutingOutcome.Unavailable); - UpdateSelection(context.Generation, selection.Profile.TransportProfileId, - capability.SelectedProfileAuthorityIdentity); - var request = new HostedRouteRequest(selection.Profile.TransportProfileId, context.Origin, context.Destination, context.Anchors, capability.SelectedProfileAuthorityIdentity!); var response = await api.GetRouteAsync(request, cancellationToken); - if (!ValidResponse(response, request)) return new(HostedRoutingOutcome.InvalidResponse); - if (!Current(context, selection.Profile.TransportProfileId, capability.SelectedProfileAuthorityIdentity!)) + if (!ValidResponse(response, request, capability)) return new(HostedRoutingOutcome.InvalidResponse); + if (!CurrentGeneration(context.Generation)) return new(HostedRoutingOutcome.Stale); - return new(HostedRoutingOutcome.Success, BuildRoute(response, context.DestinationName)); + var metadata = new HostedRouteCapabilityMetadata(capability.Provider!, + capability.ProviderConfigurationId!.Value, capability.MappingIdentity!, capability.StorageMode!); + var candidateContext = context with { SelectedTransportProfileId = selection.Profile.TransportProfileId, + SelectedProfileAuthorityIdentity = capability.SelectedProfileAuthorityIdentity }; + var candidate = new HostedRouteCandidate(BuildRoute(response, context.DestinationName), candidateContext, + selection.Profile.TransportProfileId, capability.SelectedProfileAuthorityIdentity!, metadata); + return new(HostedRoutingOutcome.Success, Candidate: candidate); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return new(HostedRoutingOutcome.Cancelled); } - catch (Exception ex) + catch (Exception) { - logger.LogWarning(ex, "Hosted routing failed locally"); + logger.LogWarning("Hosted routing failed locally: transport-or-contract-error"); return new(HostedRoutingOutcome.Unavailable); } finally { lock (stateLock) - if ((currentState ?? activeState)?.Generation == context.Generation) IsLoading = false; + if (activeGeneration == context.Generation) IsLoading = false; } } @@ -78,67 +76,54 @@ public void SelectDirect(long generation) { lock (stateLock) { - activeState = activeState is { } state - ? state with { Generation = generation, NavigationChoice = "direct", SelectedProfileId = null, - SelectedAuthorityIdentity = null } - : null; + activeGeneration = generation; IsLoading = false; } } - private bool Current(HostedRouteRequestContext context, Guid profileId, string authority) - { - HostedRoutingState? state; - lock (stateLock) state = currentState ?? activeState; - if (state == null) return true; - var points = new[] { context.Origin }.Concat(context.Anchors).Append(context.Destination); - return state.Generation == context.Generation - && state.SessionAuthority == context.SessionAuthority - && state.NormalizedServer == context.NormalizedServer - && state.SelectedProfileId == profileId - && state.SelectedAuthorityIdentity == authority - && state.TargetAssociation == context.TargetAssociation - && state.NavigationChoice == context.NavigationChoice - && state.CanonicalCoordinates.SequenceEqual(HostedRouteIdentity.Canonicalize(points)); - } - - private void Begin(HostedRouteRequestContext context) + private bool Begin(HostedRouteRequestContext context) { - if (currentState != null) return; - var points = new[] { context.Origin }.Concat(context.Anchors).Append(context.Destination); lock (stateLock) { - activeState = new(context.Generation, context.SessionAuthority, context.NormalizedServer, null, null, - context.TargetAssociation, context.NavigationChoice, HostedRouteIdentity.Canonicalize(points)); + if (context.Generation < activeGeneration) return false; + activeGeneration = context.Generation; IsLoading = true; + return true; } } - private void UpdateSelection(long generation, Guid profileId, string? authority) + private bool CurrentGeneration(long generation) { - if (currentState != null) return; - lock (stateLock) - if (activeState?.Generation == generation) - activeState = activeState with { SelectedProfileId = profileId, - SelectedAuthorityIdentity = authority ?? activeState.SelectedAuthorityIdentity }; + lock (stateLock) return activeGeneration == generation; } private static bool AvailableCatalog(HostedRoutingCatalog value) => value.Outcome == "available" - && ValidIdentity(value.DiscoveryCatalogIdentity) && value.Profiles.Count is > 0 and <= 100 + && 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(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 && ValidIdentity(value.SelectedProfileAuthorityIdentity) + && 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) + private static bool ValidResponse(HostedRouteResponse value, HostedRouteRequest request, + HostedRoutingCapability capability) { if (!value.Succeeded || value.Outcome != "available" || value.TransportProfileId != request.TransportProfileId || value.SelectedProfileAuthorityIdentity != request.SelectedProfileAuthorityIdentity - || value.GeneratedAt is null || value.Geometry is not { Count: >= 2 and <= MaximumGeometry } + || !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 + || generatedAt > DateTimeOffset.UtcNow.AddMinutes(5) + || 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 @@ -173,7 +158,6 @@ private static bool ValidResponse(HostedRouteResponse value, HostedRouteRequest 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 ValidIdentity(string? value) => value is { Length: >= 4 and <= 64 } && value.StartsWith("v1.", StringComparison.Ordinal); 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); diff --git a/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs index ce65d5a..7ab6cb6 100644 --- a/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs +++ b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs @@ -20,7 +20,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 +68,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; } @@ -328,13 +328,14 @@ await OpenExternalMapsAsync( _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:{SelectedMember.UserId}"); // Close bottom sheet before navigating IsMemberSheetOpen = false; diff --git a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs index 7709738..6f161b6 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -1,8 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; -using System.Security.Cryptography; -using System.Text; +using Microsoft.Maui.ApplicationModel; using WayfarerMobile.Core.Enums; using WayfarerMobile.Core.Interfaces; using WayfarerMobile.Core.Models; @@ -29,6 +28,7 @@ public partial class NavigationCoordinatorViewModel : BaseViewModel private readonly ITripStateManager _tripState; private CancellationTokenSource? _hostedRoutingCancellation; private long _hostedRoutingGeneration; + private HostedRouteRequestContext? _hostedContext; // Callbacks to parent ViewModel private INavigationCallbacks? _callbacks; @@ -137,6 +137,8 @@ public async Task StartNavigationToPlaceAsync(string placeId) return; } + CancelHostedRouting(); + var route = _tripNavigationService.CalculateRouteToPlace( currentLocation.Latitude, currentLocation.Longitude, @@ -181,10 +183,25 @@ public async Task StartNavigationToNextAsync() return; } + CancelHostedRouting(); + var route = _tripNavigationService.CalculateRouteToNextPlace( currentLocation.Latitude, currentLocation.Longitude); + if (route?.IsDirectRoute == true && route.Waypoints.Count > 0) + { + var destination = route.Waypoints[^1]; + var place = _tripState.LoadedTrip?.AllPlaces.FirstOrDefault(item => + item.Latitude == destination.Latitude && item.Longitude == destination.Longitude); + var segment = place == null ? null : FindCurrentSegment( + place.Id, currentLocation.Latitude, currentLocation.Longitude); + route = await TryHostedAsync(route, currentLocation.Latitude, currentLocation.Longitude, + destination.Latitude, destination.Longitude, route.DestinationName, + segment?.TransportMode ?? "walk", HostedSegmentProfileIdentity.Get(segment), + ResolveAnchors(segment), $"trip-next:{place?.Id.ToString() ?? "unknown"}"); + } + if (route != null) { // Track navigation destination for visit notification conflict detection @@ -288,16 +305,33 @@ public async Task CalculateRouteToCoordinatesAsync( profile, null, [], "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) + { + var direct = await _tripNavigationService.CalculateRouteToCoordinatesAsync( + fromLat, fromLon, toLat, toLon, destinationName, profile); + return await TryHostedAsync(direct, fromLat, fromLon, toLat, toLon, destinationName, + profile, null, [], targetAssociation); + } + private async Task TryHostedAsync(NavigationRoute direct, double fromLat, double fromLon, double toLat, double toLon, string destinationName, string profile, Guid? savedProfileId, IReadOnlyList anchors, string targetAssociation) { var generation = Interlocked.Increment(ref _hostedRoutingGeneration); CancelHostedRouting(incrementGeneration: false); - if (profile == "direct") { _hostedRouting.SelectDirect(generation); return direct; } + if (profile == "direct") + { + _hostedContext = null; + _hostedRouting.SelectDirect(generation); + return direct; + } _hostedRoutingCancellation = new CancellationTokenSource(); var context = CreateHostedContext(fromLat, fromLon, toLat, toLon, destinationName, profile, generation, savedProfileId, anchors, targetAssociation); + _hostedContext = context; var result = await _hostedRouting.RequestRouteAsync(context, cancellationToken: _hostedRoutingCancellation.Token); if (result.Outcome == HostedRoutingOutcome.RequiresChoice && result.Choices is { Count: > 0 }) { @@ -310,10 +344,17 @@ private async Task TryHostedAsync(NavigationRoute direct, doubl _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); return direct; } + if (_hostedRoutingGeneration != generation || _hostedContext?.Generation != generation) return direct; result = await _hostedRouting.RequestRouteAsync(context, result.Choices[index], _hostedRoutingCancellation.Token); } - if (result.Outcome != HostedRoutingOutcome.Success || result.Route == null) return direct; - CopyRoute(result.Route, direct); + if (result.Outcome != HostedRoutingOutcome.Success || result.Candidate == null) return direct; + if (_hostedRoutingGeneration != generation || _hostedContext?.Generation != generation) return direct; + _hostedContext = result.Candidate.Context; + await MainThread.InvokeOnMainThreadAsync(() => + { + var live = CreateLiveContext(); + if (live != null) HostedRoutePublication.TryPublish(result.Candidate, live, direct); + }); return direct; } @@ -322,14 +363,31 @@ private HostedRouteRequestContext CreateHostedContext(double fromLat, double fro IReadOnlyList anchors, string targetAssociation) { var mode = profile switch { "foot" => "walk", "car" => "drive", "bike" => "bicycle", _ => profile }; - var server = Uri.TryCreate(_settings.ServerUrl, UriKind.Absolute, out var uri) - ? uri.GetLeftPart(UriPartial.Authority).TrimEnd('/').ToLowerInvariant() : string.Empty; - var token = _settings.ApiToken ?? string.Empty; - var sessionAuthority = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + var server = NormalizeServer(_settings.ServerUrl); + var sessionAuthority = _settings.ApiToken ?? string.Empty; return new(savedProfileId, mode, mode, new(fromLon, fromLat), new(toLon, toLat), anchors, destinationName, generation, sessionAuthority, server, targetAssociation, "hosted"); } + private HostedRouteRequestContext? CreateLiveContext() + { + if (_hostedContext == null) return null; + var server = NormalizeServer(_settings.ServerUrl); + return _hostedContext with + { + Generation = _hostedRoutingGeneration, + SessionAuthority = _settings.ApiToken ?? string.Empty, + NormalizedServer = server + }; + } + + 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 TripSegment? FindCurrentSegment(Guid destinationId, double latitude, double longitude) => _tripState.LoadedTrip?.Segments.Where(item => item.DestinationId == destinationId) .Select(item => (Segment: item, Origin: _tripState.LoadedTrip.AllPlaces @@ -352,21 +410,10 @@ private IReadOnlyList ResolveAnchors(TripSegment? segment return result; } - private static void CopyRoute(NavigationRoute source, NavigationRoute target) - { - 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; - } - private void CancelHostedRouting(bool incrementGeneration = true) { if (incrementGeneration) _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); + if (incrementGeneration) _hostedContext = null; _hostedRoutingCancellation?.Cancel(); _hostedRoutingCancellation?.Dispose(); _hostedRoutingCancellation = null; @@ -377,6 +424,7 @@ private void CancelHostedRouting(bool incrementGeneration = true) /// public async Task StartNavigationWithRouteAsync(NavigationRoute route) { + CancelHostedRouting(); _currentNavigationPlaceId = null; _visitNotificationService.UpdateNavigationState(true, null); diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs index 6ae006f..816b9fc 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs @@ -52,11 +52,16 @@ public void CandidatePublishesOnlyWhenAllLiveAuthorityStillMatches() private static HostedRouteCandidate Candidate() { - var context = HostedRouteRequestContext.ForTest( - Guid.Parse("11111111-1111-1111-1111-111111111111")); + var profileId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var context = HostedRouteRequestContext.ForTest(profileId) with + { + SelectedTransportProfileId = profileId, + SelectedProfileAuthorityIdentity = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }; return new HostedRouteCandidate(RoutedRoute(), context, - Guid.Parse("11111111-1111-1111-1111-111111111111"), - "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + profileId, + "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + new("geoapify", Guid.Parse("22222222-2222-2222-2222-222222222222"), "mapping", "persistent")); } private static NavigationRoute DirectRoute() => new() diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs index 464b73f..3e52ca3 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs @@ -27,6 +27,7 @@ public void TripJson_CapturesCurrentProfileGuidOnlyInTransientObjectState() [Fact] public async Task ControlledFlow_UsesOnlyAuthenticatedWayfarerContractAndBothIdentities() { + const string identity = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; var profileId = Guid.Parse("11111111-1111-1111-1111-111111111111"); var requests = new List<(Uri Uri, string? Authorization, string Body)>(); var handler = new RecordingHandler(async request => @@ -35,9 +36,9 @@ public async Task ControlledFlow_UsesOnlyAuthenticatedWayfarerContractAndBothIde requests.Add((request.RequestUri!, request.Headers.Authorization?.ToString(), body)); var json = request.RequestUri!.AbsolutePath switch { - "/api/mobile/routing/profiles" => $$"""{"outcome":"available","discoveryCatalogIdentity":"v1.catalog-a","profiles":[{"transportProfileId":"{{profileId}}","displayName":"Walking","modeKey":"walk","category":"active"}]}""", - var path when path.StartsWith("/api/mobile/routing/capability/") => $$"""{"outcome":"available","transportProfileId":"{{profileId}}","provider":"geoapify","providerConfigurationId":"22222222-2222-2222-2222-222222222222","mappingIdentity":"mapping","storageMode":"persistent","attribution":[{"text":"Powered by test","url":"https://example.test"}],"discoveryCatalogIdentity":"v1.catalog-a","selectedProfileAuthorityIdentity":"v1.selected-a"}""", - "/api/mobile/routing/route" => $$"""{"succeeded":true,"outcome":"available","geometry":[{"longitude":23,"latitude":37},{"longitude":23.01,"latitude":37.01}],"distanceMetres":1500,"durationSeconds":900,"instructions":[{"text":"Continue","type":"continue","fromIndex":0,"toIndex":1,"distanceMetres":1500,"durationSeconds":900}],"generatedAt":"2026-08-30T18:00:00Z","provider":"geoapify","providerConfigurationId":"22222222-2222-2222-2222-222222222222","mappingIdentity":"mapping","transportProfileId":"{{profileId}}","matchPoints":[{"longitude":23,"latitude":37},{"longitude":23.01,"latitude":37.01}],"attribution":[{"text":"Powered by test","url":"https://example.test"}],"storageMode":"persistent","selectedProfileAuthorityIdentity":"v1.selected-a"}""", + "/api/mobile/routing/profiles" => $$"""{"outcome":"available","discoveryCatalogIdentity":"{{identity}}","profiles":[{"transportProfileId":"{{profileId}}","displayName":"Walking","modeKey":"walk","category":"active"}]}""", + var path when path.StartsWith("/api/mobile/routing/capability/") => $$"""{"outcome":"available","transportProfileId":"{{profileId}}","provider":"geoapify","providerConfigurationId":"22222222-2222-2222-2222-222222222222","mappingIdentity":"mapping","storageMode":"persistent","attribution":[{"text":"Powered by test","url":"https://example.test"}],"discoveryCatalogIdentity":"{{identity}}","selectedProfileAuthorityIdentity":"{{identity}}"}""", + "/api/mobile/routing/route" => $$"""{"succeeded":true,"outcome":"available","geometry":[{"longitude":23,"latitude":37},{"longitude":23.01,"latitude":37.01}],"distanceMetres":1500,"durationSeconds":900,"instructions":[{"text":"Continue","type":"continue","fromIndex":0,"toIndex":1,"distanceMetres":1500,"durationSeconds":900}],"generatedAt":"2026-08-30T00:00:00+02:00","provider":"geoapify","providerConfigurationId":"22222222-2222-2222-2222-222222222222","mappingIdentity":"mapping","transportProfileId":"{{profileId}}","matchPoints":[{"longitude":23,"latitude":37},{"longitude":23.01,"latitude":37.01}],"attribution":[{"text":"Powered by test","url":"https://example.test"}],"storageMode":"persistent","selectedProfileAuthorityIdentity":"{{identity}}"}""", _ => throw new InvalidOperationException("Unexpected endpoint") }; return Json(HttpStatusCode.OK, json); @@ -50,9 +51,14 @@ var path when path.StartsWith("/api/mobile/routing/capability/") => $$"""{"outco capability.SelectedProfileAuthorityIdentity!), default); route.Succeeded.Should().BeTrue(); + route.Provider.Should().Be("geoapify"); + route.ProviderConfigurationId.Should().Be(Guid.Parse("22222222-2222-2222-2222-222222222222")); + route.MappingIdentity.Should().Be("mapping"); + route.StorageMode.Should().Be("persistent"); + route.GeneratedAt.Should().Be(new DateTimeOffset(2026, 8, 29, 22, 0, 0, TimeSpan.Zero)); requests.Should().OnlyContain(item => item.Uri.Host == "wayfarer.test" && item.Authorization == "Bearer token"); - requests[1].Uri.Query.Should().Contain("discoveryCatalogIdentity=v1.catalog-a"); - requests[2].Body.Should().Contain("\"selectedProfileAuthorityIdentity\":\"v1.selected-a\"") + requests[1].Uri.Query.Should().Contain($"discoveryCatalogIdentity={identity}"); + requests[2].Body.Should().Contain($"\"selectedProfileAuthorityIdentity\":\"{identity}\"") .And.NotContain("discoveryCatalogIdentity").And.NotContain("provider").And.NotContain("apiKey"); } @@ -87,6 +93,74 @@ public async Task Discovery_IgnoresUnknownAdditiveResponseMember() catalog.Profiles.Should().ContainSingle(); } + [Fact] + public async Task Discovery_RejectsWrongKindForRequiredProfilesMember() + { + var client = Create(new RecordingHandler(_ => Task.FromResult(Json(HttpStatusCode.OK, + """{"outcome":"available","discoveryCatalogIdentity":"v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","profiles":"wrong"}""")))); + + var catalog = await client.DiscoverAsync(default); + + catalog.Outcome.Should().Be("invalid-response"); + catalog.Profiles.Should().BeEmpty(); + } + + [Fact] + public async Task Capability400InvalidRequest_IsReturnedAsTerminalOutcome() + { + var client = Create(new RecordingHandler(_ => Task.FromResult(Json(HttpStatusCode.BadRequest, + """{"outcome":"invalid-request","transportProfileId":"11111111-1111-1111-1111-111111111111"}""")))); + + var capability = await client.GetCapabilityAsync( + Guid.Parse("11111111-1111-1111-1111-111111111111"), + "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", default); + + capability.Outcome.Should().Be("invalid-request"); + } + + [Fact] + public async Task Route400InvalidRequest_IsReturnedAsTerminalOutcome() + { + var profileId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var client = Create(new RecordingHandler(_ => Task.FromResult(Json(HttpStatusCode.BadRequest, + """{"succeeded":false,"outcome":"invalid-request"}""")))); + + var route = await client.GetRouteAsync(new(profileId, new(23, 37), new(23.01, 37.01), [], + "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), default); + + route.Outcome.Should().Be("invalid-request"); + route.Succeeded.Should().BeFalse(); + } + + [Theory] + [InlineData("2026-08-30T12:00:00", false)] + [InlineData("2026-08-30T12:00:00+02:00", true)] + public async Task RouteGeneratedAt_RequiresExplicitOffsetAndNormalizesUtc(string generatedAt, bool valid) + { + const string identity = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + var profileId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var json = JsonSerializer.Serialize(new + { + succeeded = true, outcome = "available", + geometry = new[] { new { longitude = 23d, latitude = 37d }, new { longitude = 23.01, latitude = 37.01 } }, + distanceMetres = 1500d, durationSeconds = 900d, + instructions = Array.Empty(), generatedAt, provider = "geoapify", + providerConfigurationId = Guid.Parse("22222222-2222-2222-2222-222222222222"), + mappingIdentity = "mapping", transportProfileId = profileId, + matchPoints = new[] { new { longitude = 23d, latitude = 37d }, new { longitude = 23.01, latitude = 37.01 } }, + attribution = new[] { new { text = "Powered by test", url = "https://example.test" } }, + storageMode = "persistent", selectedProfileAuthorityIdentity = identity + }); + var client = Create(new RecordingHandler(_ => Task.FromResult(Json(HttpStatusCode.OK, json)))); + + var route = await client.GetRouteAsync(new(profileId, new(23, 37), new(23.01, 37.01), [], identity), default); + + if (valid) + route.GeneratedAt.Should().Be(new DateTimeOffset(2026, 8, 30, 10, 0, 0, TimeSpan.Zero)); + else + route.GeneratedAt.Should().BeNull(); + } + private static HostedRoutingApiClient Create(HttpMessageHandler handler) { var settings = new Mock(); diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs index 02e62e4..f99f6d1 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs @@ -8,6 +8,8 @@ public sealed class HostedRoutingServiceTests { private static readonly Guid WalkingProfile = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid CyclingProfile = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private const string IdentityA = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + private const string IdentityB = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ"; [Theory] [InlineData(true, "unknown", "unknown", HostedProfileSelectionKind.Selected)] @@ -33,7 +35,7 @@ public void SelectProfile_UsesGuidThenOnlyAnUnambiguousTextualHint( public void ConfirmChoice_AcceptsSameGuidAndRefreshesRenamedMetadata() { var original = Catalog(new HostedRoutingProfile(WalkingProfile, "Walking", "walk", "active")); - var renamed = new HostedRoutingCatalog("v1.catalog-b", "available", + var renamed = new HostedRoutingCatalog(IdentityB, "available", [new(WalkingProfile, "On foot", "walk", "active")]); HostedProfileSelector.Confirm(null, original).Should().BeNull(); @@ -62,49 +64,47 @@ public async Task RequestRouteAsync_UsesCatalogForCapabilityAndSelectedAuthority api.Setup(client => client.GetCapabilityAsync( WalkingProfile, catalog.DiscoveryCatalogIdentity!, It.IsAny())) .ReturnsAsync(HostedRoutingCapability.Available( - WalkingProfile, catalog.DiscoveryCatalogIdentity!, "v1.selected-a", Attribution())); + WalkingProfile, catalog.DiscoveryCatalogIdentity!, IdentityA, Attribution())); api.Setup(client => client.GetRouteAsync( It.Is(request => request.TransportProfileId == WalkingProfile - && request.SelectedProfileAuthorityIdentity == "v1.selected-a"), + && request.SelectedProfileAuthorityIdentity == IdentityA), It.IsAny())) - .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, "v1.selected-a")); + .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA)); var service = new HostedRoutingService(api.Object, NullLogger.Instance); var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest( WalkingProfile, expectedCatalogIdentity: catalog.DiscoveryCatalogIdentity)); result.Outcome.Should().Be(HostedRoutingOutcome.Success); - result.Route.Should().NotBeNull(); - result.Route!.IsDirectRoute.Should().BeFalse(); - result.Route.Attribution.Should().ContainSingle(item => item.Text == "Powered by Wayfarer test"); + result.Candidate.Should().NotBeNull(); + result.Candidate!.Route.IsDirectRoute.Should().BeFalse(); + result.Candidate.Route.Attribution.Should().ContainSingle(item => item.Text == "Powered by Wayfarer test"); api.VerifyAll(); } [Fact] public async Task RequestRouteAsync_UnrelatedCatalogChangeAfterCapability_DoesNotInvalidateSelectedAuthority() { - var api = SuccessfulApi(WalkingProfile, "v1.catalog-a", "v1.selected-a"); - var state = HostedRoutingState.ForTest(catalogIdentity: "v1.catalog-b", selectedAuthorityIdentity: "v1.selected-a"); - var service = new HostedRoutingService(api.Object, NullLogger.Instance, state); + var api = SuccessfulApi(WalkingProfile, IdentityA, IdentityA); + var service = new HostedRoutingService(api.Object, NullLogger.Instance); var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest( - WalkingProfile, expectedCatalogIdentity: "v1.catalog-a")); + WalkingProfile, expectedCatalogIdentity: IdentityA)); result.Outcome.Should().Be(HostedRoutingOutcome.Success); } [Fact] - public async Task RequestRouteAsync_SelectedAuthorityChangeBeforePublication_DiscardsResponse() + public void Publication_SelectedAuthorityChangeBeforePublication_DiscardsCandidate() { - var api = SuccessfulApi(WalkingProfile, "v1.catalog-a", "v1.selected-a"); - var state = HostedRoutingState.ForTest(selectedAuthorityIdentity: "v1.selected-b"); - var service = new HostedRoutingService(api.Object, NullLogger.Instance, state); - - var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest( - WalkingProfile, expectedCatalogIdentity: "v1.catalog-a")); - - result.Outcome.Should().Be(HostedRoutingOutcome.Stale); - result.Route.Should().BeNull(); + var context = HostedRouteRequestContext.ForTest(WalkingProfile) with + { SelectedTransportProfileId = WalkingProfile, SelectedProfileAuthorityIdentity = IdentityA }; + var candidate = new HostedRouteCandidate(new WayfarerMobile.Core.Models.NavigationRoute(), + context, WalkingProfile, IdentityA, + new("geoapify", CyclingProfile, "mapping", "persistent")); + var live = context with { SelectedProfileAuthorityIdentity = IdentityB }; + + HostedRoutePublication.Current(candidate, live).Should().BeFalse(); } [Fact] @@ -112,10 +112,10 @@ public async Task RequestRouteAsync_ACompletesLast_OnlyBCanPublishOrClearLoading { var aCompletion = new TaskCompletionSource(); var aStarted = new TaskCompletionSource(); - var api = SuccessfulApi(WalkingProfile, "v1.catalog-a", "v1.selected-a"); + var api = SuccessfulApi(WalkingProfile, IdentityA, IdentityA); api.SetupSequence(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) .Returns(() => { aStarted.SetResult(); return aCompletion.Task; }) - .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, "v1.selected-a")); + .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA)); var service = new HostedRoutingService(api.Object, NullLogger.Instance); var a = service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile) with { Generation = 1 }); await aStarted.Task; @@ -123,7 +123,7 @@ public async Task RequestRouteAsync_ACompletesLast_OnlyBCanPublishOrClearLoading (await b).Outcome.Should().Be(HostedRoutingOutcome.Success); service.IsLoading.Should().BeFalse(); - aCompletion.SetResult(HostedRouteResponse.ValidForTest(WalkingProfile, "v1.selected-a")); + aCompletion.SetResult(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA)); (await a).Outcome.Should().Be(HostedRoutingOutcome.Stale); service.IsLoading.Should().BeFalse(); } @@ -133,7 +133,7 @@ public async Task SelectDirect_WhileHostedRequestIsInFlight_DiscardsHostedRespon { var completion = new TaskCompletionSource(); var started = new TaskCompletionSource(); - var api = SuccessfulApi(WalkingProfile, "v1.catalog-a", "v1.selected-a"); + var api = SuccessfulApi(WalkingProfile, IdentityA, IdentityA); api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) .Returns(() => { started.SetResult(); return completion.Task; }); var service = new HostedRoutingService(api.Object, NullLogger.Instance); @@ -141,12 +141,64 @@ public async Task SelectDirect_WhileHostedRequestIsInFlight_DiscardsHostedRespon await started.Task; service.SelectDirect(2); - completion.SetResult(HostedRouteResponse.ValidForTest(WalkingProfile, "v1.selected-a")); + completion.SetResult(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA)); (await pending).Outcome.Should().Be(HostedRoutingOutcome.Stale); service.IsLoading.Should().BeFalse(); } + [Fact] + public async Task CapabilityAndRouteMetadata_MustMatchAndUnknownStorageRemainsTransientlyUsable() + { + var api = SuccessfulApi(WalkingProfile, IdentityA, IdentityA); + api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityA, It.IsAny())) + .ReturnsAsync(HostedRoutingCapability.Available(WalkingProfile, IdentityA, IdentityA, Attribution(), + mappingIdentity: "mapping-v2", storageMode: "future-transient")); + api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA) with + { MappingIdentity = "mapping-v2", StorageMode = "future-transient" }); + var service = new HostedRoutingService(api.Object, NullLogger.Instance); + + var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile)); + + result.Outcome.Should().Be(HostedRoutingOutcome.Success); + result.Candidate!.Metadata.Should().Be(new HostedRouteCapabilityMetadata("geoapify", + Guid.Parse("22222222-2222-2222-2222-222222222222"), "mapping-v2", "future-transient")); + } + + [Theory] + [InlineData("invalid-request")] + [InlineData("catalog-changed")] + public async Task TerminalCapabilityOutcome_MakesNoRouteContact(string outcome) + { + var api = SuccessfulApi(WalkingProfile, IdentityA, IdentityA); + api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityA, It.IsAny())) + .ReturnsAsync(new HostedRoutingCapability(outcome, WalkingProfile, null, null, null, null, + null, outcome == "catalog-changed" ? null : IdentityA, null)); + var service = new HostedRoutingService(api.Object, NullLogger.Instance); + + await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile)); + + api.Verify(client => client.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("invalid-request")] + [InlineData("authority-changed")] + public async Task TerminalRouteOutcome_IsNotRetried(string outcome) + { + var api = SuccessfulApi(WalkingProfile, IdentityA, IdentityA); + api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA) with + { Succeeded = false, Outcome = outcome }); + var service = new HostedRoutingService(api.Object, NullLogger.Instance); + + var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile)); + + result.Outcome.Should().Be(HostedRoutingOutcome.InvalidResponse); + api.Verify(client => client.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Once); + } + [Theory] [InlineData("unavailable")] [InlineData("routing-disabled")] @@ -167,6 +219,26 @@ public async Task DiscoveryUnavailable_RemainsLocalAndMakesNoCapabilityOrRouteRe It.IsAny()), Times.Never); } + [Fact] + public async Task HostedFailureLeavesDirectRouteUnchanged() + { + var api = new Mock(MockBehavior.Strict); + api.Setup(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(new HostedRoutingCatalog(null, "routing-disabled", [])); + var service = new HostedRoutingService(api.Object, NullLogger.Instance); + var direct = new WayfarerMobile.Core.Models.NavigationRoute + { + IsDirectRoute = true, + Waypoints = [new() { Longitude = 23, Latitude = 37 }, new() { Longitude = 23.01, Latitude = 37.01 }] + }; + + var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile)); + + result.Candidate.Should().BeNull(); + direct.IsDirectRoute.Should().BeTrue(); + direct.Attribution.Should().BeEmpty(); + } + [Fact] public void Canonicalize_UsesLongitudeLatitudeAwayFromZeroAndPreservesDuplicates() { @@ -185,7 +257,7 @@ public void Canonicalize_RejectsInvalidWgs84Coordinates() } private static HostedRoutingCatalog Catalog(params HostedRoutingProfile[] profiles) => - new("v1.catalog-a", "available", profiles); + new(IdentityA, "available", profiles); private static Mock SuccessfulApi(Guid profileId, string catalogIdentity, string authorityIdentity) diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs index d537db1..ad9af61 100644 --- a/tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs @@ -19,6 +19,7 @@ public void NextPlaceDirectPathUsesSharedHostedOwner() method = method[..method.IndexOf("/// ", StringComparison.Ordinal)]; method.Should().Contain("TryHostedAsync"); + method.Should().Contain("route?.IsDirectRoute == true"); } private static string ReadSource(string fileName) From 7d9b0cfbd2b5e44c4e3c45a65c1bee6d4f7a39fb Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 22:36:31 +0300 Subject: [PATCH 10/18] fix: bound member routing diagnostics --- src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs index 7ab6cb6..971d7a2 100644 --- a/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs +++ b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs @@ -349,9 +349,9 @@ 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"); } } From dd4f4b027c598365d11c82a0fffc7858d2c46b16 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 22:39:25 +0300 Subject: [PATCH 11/18] fix: retain hosted route generation metadata --- src/WayfarerMobile/Services/HostedRoutingModels.cs | 3 ++- src/WayfarerMobile/Services/HostedRoutingService.cs | 3 ++- .../Unit/Services/HostedRoutePublicationTests.cs | 3 ++- .../Unit/Services/HostedRoutingServiceTests.cs | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/WayfarerMobile/Services/HostedRoutingModels.cs b/src/WayfarerMobile/Services/HostedRoutingModels.cs index 04409b6..7bcf224 100644 --- a/src/WayfarerMobile/Services/HostedRoutingModels.cs +++ b/src/WayfarerMobile/Services/HostedRoutingModels.cs @@ -79,7 +79,8 @@ public sealed record HostedRouteCapabilityMetadata(string Provider, Guid Provide string MappingIdentity, string StorageMode); public sealed record HostedRouteCandidate(NavigationRoute Route, HostedRouteRequestContext Context, - Guid SelectedProfileId, string SelectedProfileAuthorityIdentity, HostedRouteCapabilityMetadata Metadata); + Guid SelectedProfileId, string SelectedProfileAuthorityIdentity, HostedRouteCapabilityMetadata Metadata, + DateTimeOffset GeneratedAt); public sealed record HostedRouteRequestContext(Guid? SavedTransportProfileId, string? ModeKey, string? Category, HostedRouteCoordinate Origin, HostedRouteCoordinate Destination, IReadOnlyList Anchors, diff --git a/src/WayfarerMobile/Services/HostedRoutingService.cs b/src/WayfarerMobile/Services/HostedRoutingService.cs index 76d224c..ccd94ca 100644 --- a/src/WayfarerMobile/Services/HostedRoutingService.cs +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -53,7 +53,8 @@ public async Task RequestRouteAsync(HostedRouteRequestConte var candidateContext = context with { SelectedTransportProfileId = selection.Profile.TransportProfileId, SelectedProfileAuthorityIdentity = capability.SelectedProfileAuthorityIdentity }; var candidate = new HostedRouteCandidate(BuildRoute(response, context.DestinationName), candidateContext, - selection.Profile.TransportProfileId, capability.SelectedProfileAuthorityIdentity!, metadata); + selection.Profile.TransportProfileId, capability.SelectedProfileAuthorityIdentity!, metadata, + response.GeneratedAt!.Value); return new(HostedRoutingOutcome.Success, Candidate: candidate); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs index 816b9fc..a890631 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs @@ -61,7 +61,8 @@ private static HostedRouteCandidate Candidate() return new HostedRouteCandidate(RoutedRoute(), context, profileId, "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - new("geoapify", Guid.Parse("22222222-2222-2222-2222-222222222222"), "mapping", "persistent")); + new("geoapify", Guid.Parse("22222222-2222-2222-2222-222222222222"), "mapping", "persistent"), + DateTimeOffset.UtcNow); } private static NavigationRoute DirectRoute() => new() diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs index f99f6d1..09db2db 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs @@ -101,7 +101,7 @@ public void Publication_SelectedAuthorityChangeBeforePublication_DiscardsCandida { SelectedTransportProfileId = WalkingProfile, SelectedProfileAuthorityIdentity = IdentityA }; var candidate = new HostedRouteCandidate(new WayfarerMobile.Core.Models.NavigationRoute(), context, WalkingProfile, IdentityA, - new("geoapify", CyclingProfile, "mapping", "persistent")); + new("geoapify", CyclingProfile, "mapping", "persistent"), DateTimeOffset.UtcNow); var live = context with { SelectedProfileAuthorityIdentity = IdentityB }; HostedRoutePublication.Current(candidate, live).Should().BeFalse(); @@ -164,6 +164,7 @@ public async Task CapabilityAndRouteMetadata_MustMatchAndUnknownStorageRemainsTr result.Outcome.Should().Be(HostedRoutingOutcome.Success); result.Candidate!.Metadata.Should().Be(new HostedRouteCapabilityMetadata("geoapify", Guid.Parse("22222222-2222-2222-2222-222222222222"), "mapping-v2", "future-transient")); + result.Candidate.GeneratedAt.Offset.Should().Be(TimeSpan.Zero); } [Theory] From 520222f9b72dee7182c8dd1973748c62c0122108 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 22:42:18 +0300 Subject: [PATCH 12/18] fix: follow hosted timestamp contract --- src/WayfarerMobile/Services/HostedRoutingService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/WayfarerMobile/Services/HostedRoutingService.cs b/src/WayfarerMobile/Services/HostedRoutingService.cs index ccd94ca..c430ddd 100644 --- a/src/WayfarerMobile/Services/HostedRoutingService.cs +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -123,7 +123,6 @@ private static bool ValidResponse(HostedRouteResponse value, HostedRouteRequest || value.ProviderConfigurationId != capability.ProviderConfigurationId || value.MappingIdentity != capability.MappingIdentity || value.StorageMode != capability.StorageMode || value.GeneratedAt is not { } generatedAt || generatedAt.Offset != TimeSpan.Zero - || generatedAt > DateTimeOffset.UtcNow.AddMinutes(5) || 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) From df2d5cc67d6ca239721d64251c00f2d89793ed13 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 23:08:10 +0300 Subject: [PATCH 13/18] WIP: prove live publication ownership corrections (checkpoint; tests failing) --- .../Services/HostedRoutePublicationTests.cs | 59 +++++++++---- .../TripNavigationRoutingRemovalTests.cs | 82 +++++++++++++++++++ .../HostedRoutingTriggerIntegrationTests.cs | 30 ------- 3 files changed, 124 insertions(+), 47 deletions(-) delete mode 100644 tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs index a890631..6ecb34d 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs @@ -12,27 +12,34 @@ public void CandidateCannotOverwriteNewerOrDirectRoute(long liveGeneration, stri { var direct = DirectRoute(); var candidate = Candidate(); - var live = candidate.Context with { Generation = liveGeneration, NavigationChoice = liveChoice }; + var live = Live(candidate) with { Generation = liveGeneration, NavigationChoice = liveChoice }; HostedRoutePublication.TryPublish(candidate, live, direct).Should().BeFalse(); direct.IsDirectRoute.Should().BeTrue(); direct.Attribution.Should().BeEmpty(); } - [Theory] - [InlineData("different-session", "https://wayfarer.test", "place:test")] - [InlineData("session", "https://other.test", "place:test")] - [InlineData("session", "https://wayfarer.test", "member:other")] - public void CandidateCannotPublishAfterLiveAuthorityDriftsWithoutAnotherHostedRequest( - string session, string server, string target) + [Fact] + public void DelayedCandidateCannotPublishAfterLiveLocationChanges() { var direct = DirectRoute(); var candidate = Candidate(); - var live = candidate.Context with + var live = Live(candidate) with { Origin = new(23.0002, 37.0002) }; + + HostedRoutePublication.TryPublish(candidate, live, direct).Should().BeFalse(); + direct.IsDirectRoute.Should().BeTrue(); + direct.Attribution.Should().BeEmpty(); + direct.HostedProvenance.Should().BeNull(); + } + + [Fact] + public void CandidateCannotPublishAfterAuthenticationSessionRevisionChanges() + { + var direct = DirectRoute(); + var candidate = Candidate(); + var live = Live(candidate) with { - SessionAuthority = session, - NormalizedServer = server, - TargetAssociation = target + AuthenticationSessionRevision = candidate.Context.AuthenticationSessionRevision + 1 }; HostedRoutePublication.TryPublish(candidate, live, direct).Should().BeFalse(); @@ -45,19 +52,23 @@ public void CandidatePublishesOnlyWhenAllLiveAuthorityStillMatches() var direct = DirectRoute(); var candidate = Candidate(); - HostedRoutePublication.TryPublish(candidate, candidate.Context, direct).Should().BeTrue(); + HostedRoutePublication.TryPublish(candidate, Live(candidate), direct).Should().BeTrue(); direct.IsDirectRoute.Should().BeFalse(); direct.Attribution.Should().ContainSingle(); + direct.HostedProvenance.Should().Be(new HostedRouteProvenance( + candidate.SelectedProfileId, + candidate.SelectedProfileAuthorityIdentity, + candidate.Metadata.Provider, + candidate.Metadata.ProviderConfigurationId, + candidate.Metadata.MappingIdentity, + candidate.Metadata.StorageMode, + candidate.GeneratedAt)); } private static HostedRouteCandidate Candidate() { var profileId = Guid.Parse("11111111-1111-1111-1111-111111111111"); - var context = HostedRouteRequestContext.ForTest(profileId) with - { - SelectedTransportProfileId = profileId, - SelectedProfileAuthorityIdentity = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - }; + var context = HostedRouteRequestContext.ForTest(profileId); return new HostedRouteCandidate(RoutedRoute(), context, profileId, "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", @@ -65,6 +76,20 @@ private static HostedRouteCandidate Candidate() DateTimeOffset.UtcNow); } + private static HostedRouteLiveAuthority Live(HostedRouteCandidate candidate) => new( + candidate.Context.Generation, + candidate.Context.AuthenticationSessionRevision, + candidate.Context.NormalizedServer, + candidate.Context.Origin, + candidate.Context.Destination, + candidate.Context.Anchors, + candidate.Context.TargetAssociation, + candidate.Context.SegmentId, + candidate.Context.SavedTransportProfileId, + candidate.SelectedProfileId, + candidate.SelectedProfileAuthorityIdentity, + candidate.Context.NavigationChoice); + private static NavigationRoute DirectRoute() => new() { IsDirectRoute = true, diff --git a/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs index 02b34be..937b863 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/TripNavigationRoutingRemovalTests.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Moq; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using WayfarerMobile.Core.Enums; using WayfarerMobile.Core.Interfaces; using WayfarerMobile.Core.Models; @@ -82,6 +84,21 @@ public async Task StopNavigation_ClearsRouteAndPreventsFurtherUpdates() announcements.Should().HaveCount(announcementCountAtStop); } + [Fact] + public async Task HostedProvenance_ClearsThroughNormalReplacementAndStop() + { + var navigation = CreateNavigation(); + var first = await navigation.CalculateRouteToCoordinatesAsync(0, 0, 0.001, 0, "Hosted"); + first.HostedProvenance = Provenance(); + + var replacement = await navigation.CalculateRouteToCoordinatesAsync(0, 0, 0, 0.001, "Direct"); + + navigation.ActiveRoute.Should().BeSameAs(replacement); + replacement.HostedProvenance.Should().BeNull(); + navigation.StopNavigation(); + navigation.ActiveRoute.Should().BeNull(); + } + [Fact] public async Task Arrival_PublishesCompletionThenClearsRoute() { @@ -205,6 +222,47 @@ public void TripPlaceNavigation_WithoutSavedPath_ReturnsExplicitDirectRoute() route!.IsDirectRoute.Should().BeTrue(); } + [Fact] + public void NextPlace_CoLocatedPlacesResolveExactSelectedPlaceSegmentAndAnchors() + { + var origin = Place("Origin", 0, 0, 0); + var selected = Place("Selected", 0.01, 0.01, 1); + var colocated = Place("Co-located", 0.01, 0.01, 2); + var selectedAnchor = Place("Selected anchor", 0.005, 0.006, 10); + var otherAnchor = Place("Other anchor", 0.007, 0.008, 11); + var selectedProfile = Guid.NewGuid(); + var selectedSegment = Segment(origin.Id, selected.Id, selectedAnchor.Id, selectedProfile); + var otherSegment = Segment(origin.Id, colocated.Id, otherAnchor.Id, Guid.NewGuid()); + var trip = new TripDetails + { + Id = Guid.NewGuid(), + Name = "Co-located targets", + Regions = [new TripRegion + { + Id = Guid.NewGuid(), Name = "Region", + Places = [origin, selected, colocated, selectedAnchor, otherAnchor] + }], + Segments = [selectedSegment, otherSegment] + }; + var state = new Mock(); + state.SetupGet(service => service.LoadedTrip).Returns(trip); + var navigation = CreateNavigation(state.Object); + navigation.LoadTrip(trip).Should().BeTrue(); + + var route = navigation.CalculateRouteToNextPlace(origin.Latitude, origin.Longitude); + var destinationId = Guid.Parse(route!.Waypoints[^1].PlaceId!); + var authority = HostedTripTargetAuthority.Resolve( + trip, destinationId, origin.Latitude, origin.Longitude); + + destinationId.Should().Be(selected.Id); + authority.Should().NotBeNull(); + authority!.DestinationPlaceId.Should().Be(selected.Id); + authority.SegmentId.Should().Be(selectedSegment.Id); + authority.SavedTransportProfileId.Should().Be(selectedProfile); + authority.Anchors.Should().Equal(new HostedRouteCoordinate( + selectedAnchor.Longitude, selectedAnchor.Latitude)); + } + private static TripNavigationService CreateNavigation( ITripStateManager? state = null, INavigationAudioService? audio = null) => @@ -248,4 +306,28 @@ private static (TripDetails Trip, CoreTripPlace Origin, CoreTripPlace Destinatio return (trip, origin, destination); } + + private static CoreTripPlace Place(string name, double latitude, double longitude, int sortOrder) => new() + { + Id = Guid.NewGuid(), Name = name, Latitude = latitude, Longitude = longitude, SortOrder = sortOrder + }; + + private static CoreTripSegment Segment(Guid originId, Guid destinationId, Guid anchorId, Guid profileId) + { + var resolver = new DefaultJsonTypeInfoResolver(); + resolver.Modifiers.Add(HostedSegmentProfileIdentity.Configure); + var segment = JsonSerializer.Deserialize( + $$"""{"id":"{{Guid.NewGuid()}}","fromPlaceId":"{{originId}}","toPlaceId":"{{destinationId}}","mode":"walking","transportProfileId":"{{profileId}}","waypoints":[{"placeId":"{{anchorId}}","position":0}]}""", + new JsonSerializerOptions { TypeInfoResolver = resolver }); + return segment!; + } + + private static HostedRouteProvenance Provenance() => new( + Guid.NewGuid(), + "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "geoapify", + Guid.NewGuid(), + "mapping", + "persistent", + DateTimeOffset.UtcNow); } diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs deleted file mode 100644 index ad9af61..0000000 --- a/tests/WayfarerMobile.Tests/Unit/ViewModels/HostedRoutingTriggerIntegrationTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace WayfarerMobile.Tests.Unit.ViewModels; - -public sealed class HostedRoutingTriggerIntegrationTests -{ - [Fact] - public void MemberDirectPathUsesSharedCoordinatorInsteadOfTripNavigationService() - { - var source = ReadSource("MemberDetailsViewModel.cs"); - - source.Should().Contain("CalculateHostedRouteToCoordinatesAsync") - .And.NotContain("_tripNavigationService.CalculateRouteToCoordinatesAsync"); - } - - [Fact] - public void NextPlaceDirectPathUsesSharedHostedOwner() - { - var source = ReadSource("NavigationCoordinatorViewModel.cs"); - var method = source[source.IndexOf("StartNavigationToNextAsync", StringComparison.Ordinal)..]; - method = method[..method.IndexOf("/// ", StringComparison.Ordinal)]; - - method.Should().Contain("TryHostedAsync"); - method.Should().Contain("route?.IsDirectRoute == true"); - } - - private static string ReadSource(string fileName) - { - var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); - return File.ReadAllText(Path.Combine(root, "src", "WayfarerMobile", "ViewModels", fileName)); - } -} From b66c2065d4690e73d2ec9120dfb7b20e0778fa5f Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 23:17:22 +0300 Subject: [PATCH 14/18] WIP: correct live hosted publication ownership (checkpoint) --- CHANGELOG.md | 2 +- docs/03-Features.md | 3 +- docs/07-Troubleshooting.md | 6 +- docs/11-Architecture.md | 7 +- docs/12-Services.md | 18 +- docs/13-API.md | 6 +- docs/15-Security.md | 7 +- .../Interfaces/ISettingsService.cs | 5 + .../Models/NavigationRoute.cs | 13 ++ .../Services/HostedRoutingModels.cs | 107 +++++++++-- .../Services/HostedRoutingService.cs | 24 ++- .../SettingsService.Authentication.cs | 164 +++++++++++++++++ .../Services/SettingsService.cs | 155 +--------------- .../ViewModels/MemberDetailsViewModel.cs | 12 +- .../NavigationCoordinatorViewModel.cs | 169 +++++++++++------- .../Mocks/MockSettingsService.cs | 25 ++- .../Services/HostedRoutePublicationTests.cs | 2 + .../Services/HostedRoutingServiceTests.cs | 8 +- 18 files changed, 478 insertions(+), 255 deletions(-) create mode 100644 src/WayfarerMobile/Services/SettingsService.Authentication.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a839c7..230bb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - 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 - - Displays server-returned attribution only for the active hosted route + - 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 diff --git a/docs/03-Features.md b/docs/03-Features.md index df93488..1bbc8a2 100644 --- a/docs/03-Features.md +++ b/docs/03-Features.md @@ -361,7 +361,8 @@ Route calculation differs based on navigation context: - Distance to destination - Bearing-based heading -Direct is not road-aware. Hosted route geometry, attribution, and the chosen profile are session-only and are not retained for offline use; #261 owns offline retention. +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 13c054c..d668165 100644 --- a/docs/07-Troubleshooting.md +++ b/docs/07-Troubleshooting.md @@ -259,7 +259,11 @@ For detailed troubleshooting: ### 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 a response is stale or invalid. 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 is session-only; offline retention is deferred to #261. +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 diff --git a/docs/11-Architecture.md b/docs/11-Architecture.md index 7558476..4681858 100644 --- a/docs/11-Architecture.md +++ b/docs/11-Architecture.md @@ -431,7 +431,12 @@ The `TripNavigationService` calculates routes with the following priority: 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. The coordinator validates a returned candidate against live session, server, profile, authority, target, endpoints, choice, and generation state in the same synchronous callback that installs it. Provider credentials and provider-specific endpoints remain server-side. +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 8505ba3..ecf4af0 100644 --- a/docs/12-Services.md +++ b/docs/12-Services.md @@ -552,16 +552,22 @@ public NavigationRoute? CalculateRouteToPlace( ``` Hosted routes are authenticated, provider-neutral, session-only results. Provider credentials and provider selection -remain on Wayfarer. The active HUD displays the linked attribution returned by Wayfarer and clears it on replacement -or stop. Old servers, disabled routing, rejected requests, cancellation, malformed/stale responses, and provider +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. -`TransportProfileId` is the Segment's current planning profile identity. A returned route's selected profile and -authority metadata are immutable provenance for that transient result; they do not rewrite the Segment and are not a -durable current-profile setting. The coordinator treats service output as a candidate and performs its final live-state -comparison immediately beside the synchronous route copy. +`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 c7bedae..29b9540 100644 --- a/docs/13-API.md +++ b/docs/13-API.md @@ -482,11 +482,15 @@ Mobile never contacts a public or commercial routing provider. It discovers elig `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. +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. 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 cb44ee1..8598a18 100644 --- a/docs/15-Security.md +++ b/docs/15-Security.md @@ -103,8 +103,11 @@ The QR code for app configuration contains only: 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 keeps the returned route and provider-neutral authority metadata only for the current session; #261 owns any -future offline retention policy. +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 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 6b47b79..0cd584a 100644 --- a/src/WayfarerMobile.Core/Models/NavigationRoute.cs +++ b/src/WayfarerMobile.Core/Models/NavigationRoute.cs @@ -42,11 +42,24 @@ public class NavigationRoute /// 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/Services/HostedRoutingModels.cs b/src/WayfarerMobile/Services/HostedRoutingModels.cs index 7bcf224..8bb03c5 100644 --- a/src/WayfarerMobile/Services/HostedRoutingModels.cs +++ b/src/WayfarerMobile/Services/HostedRoutingModels.cs @@ -1,3 +1,4 @@ +using WayfarerMobile.Core.Algorithms; using WayfarerMobile.Core.Models; namespace WayfarerMobile.Services; @@ -84,13 +85,81 @@ public sealed record HostedRouteCandidate(NavigationRoute Route, HostedRouteRequ public sealed record HostedRouteRequestContext(Guid? SavedTransportProfileId, string? ModeKey, string? Category, HostedRouteCoordinate Origin, HostedRouteCoordinate Destination, IReadOnlyList Anchors, - string DestinationName, long Generation, string SessionAuthority, string NormalizedServer, - string TargetAssociation, string NavigationChoice, string? ExpectedCatalogIdentity = null, - Guid? SelectedTransportProfileId = null, string? SelectedProfileAuthorityIdentity = null) + 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, - "session", "https://wayfarer.test", "place:test", "hosted", expectedCatalogIdentity); + 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 @@ -125,34 +194,41 @@ public static bool IsValid(string? value) public static class HostedRoutePublication { - public static bool TryPublish(HostedRouteCandidate candidate, HostedRouteRequestContext live, + public static bool TryPublish(HostedRouteCandidate candidate, HostedRouteLiveAuthority live, NavigationRoute target) { if (!Current(candidate, live)) return false; - Copy(candidate.Route, target); + Copy(candidate, target); return true; } - public static bool Current(HostedRouteCandidate candidate, HostedRouteRequestContext live) + public static bool Current(HostedRouteCandidate candidate, HostedRouteLiveAuthority live) { var expected = candidate.Context; return live.Generation == expected.Generation - && live.SessionAuthority == expected.SessionAuthority + && 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)).SequenceEqual(HostedRouteIdentity.Canonicalize(Points(expected))); + && HostedRouteIdentity.Canonicalize(Points(live.Origin, live.Anchors, live.Destination)) + .SequenceEqual(HostedRouteIdentity.Canonicalize( + Points(expected.Origin, expected.Anchors, expected.Destination))); } - private static IEnumerable Points(HostedRouteRequestContext context) => - new[] { context.Origin }.Concat(context.Anchors).Append(context.Destination); + private static IEnumerable Points(HostedRouteCoordinate origin, + IReadOnlyList anchors, HostedRouteCoordinate destination) => + new[] { origin }.Concat(anchors).Append(destination); - private static void Copy(NavigationRoute source, NavigationRoute target) + private static void Copy(HostedRouteCandidate candidate, NavigationRoute target) { + var source = candidate.Route; target.Waypoints = source.Waypoints; target.Steps = source.Steps; target.DestinationName = source.DestinationName; @@ -161,6 +237,13 @@ private static void Copy(NavigationRoute source, NavigationRoute target) 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()); } } diff --git a/src/WayfarerMobile/Services/HostedRoutingService.cs b/src/WayfarerMobile/Services/HostedRoutingService.cs index c430ddd..18a2cd7 100644 --- a/src/WayfarerMobile/Services/HostedRoutingService.cs +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -12,8 +12,14 @@ public sealed class HostedRoutingService 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; @@ -46,13 +52,12 @@ public async Task RequestRouteAsync(HostedRouteRequestConte context.Destination, context.Anchors, capability.SelectedProfileAuthorityIdentity!); var response = await api.GetRouteAsync(request, cancellationToken); if (!ValidResponse(response, request, capability)) return new(HostedRoutingOutcome.InvalidResponse); - if (!CurrentGeneration(context.Generation)) + if (!SelectCurrent(context.Generation, selection.Profile.TransportProfileId, + capability.SelectedProfileAuthorityIdentity!)) return new(HostedRoutingOutcome.Stale); var metadata = new HostedRouteCapabilityMetadata(capability.Provider!, capability.ProviderConfigurationId!.Value, capability.MappingIdentity!, capability.StorageMode!); - var candidateContext = context with { SelectedTransportProfileId = selection.Profile.TransportProfileId, - SelectedProfileAuthorityIdentity = capability.SelectedProfileAuthorityIdentity }; - var candidate = new HostedRouteCandidate(BuildRoute(response, context.DestinationName), candidateContext, + var candidate = new HostedRouteCandidate(BuildRoute(response, context.DestinationName), context, selection.Profile.TransportProfileId, capability.SelectedProfileAuthorityIdentity!, metadata, response.GeneratedAt!.Value); return new(HostedRoutingOutcome.Success, Candidate: candidate); @@ -78,6 +83,7 @@ public void SelectDirect(long generation) lock (stateLock) { activeGeneration = generation; + currentSelection = null; IsLoading = false; } } @@ -88,14 +94,20 @@ private bool Begin(HostedRouteRequestContext context) { if (context.Generation < activeGeneration) return false; activeGeneration = context.Generation; + currentSelection = null; IsLoading = true; return true; } } - private bool CurrentGeneration(long generation) + private bool SelectCurrent(long generation, Guid profileId, string authorityIdentity) { - lock (stateLock) return activeGeneration == generation; + lock (stateLock) + { + if (activeGeneration != generation) return false; + currentSelection = new(generation, profileId, authorityIdentity); + return true; + } } private static bool AvailableCatalog(HostedRoutingCatalog value) => value.Outcome == "available" 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/MemberDetailsViewModel.cs b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs index 971d7a2..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; @@ -325,6 +326,7 @@ 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); @@ -335,7 +337,8 @@ await OpenExternalMapsAsync( destLon, destName, travelProfile, - $"group-member:{SelectedMember.UserId}"); + $"group-member:{targetUserId}", + () => ResolveCurrentMemberLocation(targetUserId)); // Close bottom sheet before navigating IsMemberSheetOpen = false; @@ -356,6 +359,13 @@ await OpenExternalMapsAsync( } } + 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 6f161b6..9cb9e92 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -28,7 +28,8 @@ public partial class NavigationCoordinatorViewModel : BaseViewModel private readonly ITripStateManager _tripState; private CancellationTokenSource? _hostedRoutingCancellation; private long _hostedRoutingGeneration; - private HostedRouteRequestContext? _hostedContext; + private HostedRouteRequestContext? _hostedRequest; + private HostedRouteTargetOwner? _hostedTargetOwner; // Callbacks to parent ViewModel private INavigationCallbacks? _callbacks; @@ -146,12 +147,14 @@ public async Task StartNavigationToPlaceAsync(string placeId) if (route?.IsDirectRoute == true && Guid.TryParse(placeId, out var destinationId)) { - var segment = FindCurrentSegment(destinationId, currentLocation.Latitude, currentLocation.Longitude); - var anchors = ResolveAnchors(segment); - route = await TryHostedAsync(route, currentLocation.Latitude, currentLocation.Longitude, - route.Waypoints[^1].Latitude, route.Waypoints[^1].Longitude, route.DestinationName, - segment?.TransportMode ?? "walk", HostedSegmentProfileIdentity.Get(segment), anchors, - $"trip-place:{placeId}"); + 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) @@ -188,26 +191,33 @@ public async Task StartNavigationToNextAsync() var route = _tripNavigationService.CalculateRouteToNextPlace( currentLocation.Latitude, currentLocation.Longitude); + Guid? destinationPlaceId = null; if (route?.IsDirectRoute == true && route.Waypoints.Count > 0) { var destination = route.Waypoints[^1]; - var place = _tripState.LoadedTrip?.AllPlaces.FirstOrDefault(item => - item.Latitude == destination.Latitude && item.Longitude == destination.Longitude); - var segment = place == null ? null : FindCurrentSegment( - place.Id, currentLocation.Latitude, currentLocation.Longitude); - route = await TryHostedAsync(route, currentLocation.Latitude, currentLocation.Longitude, - destination.Latitude, destination.Longitude, route.DestinationName, - segment?.TransportMode ?? "walk", HostedSegmentProfileIdentity.Get(segment), - ResolveAnchors(segment), $"trip-next:{place?.Id.ToString() ?? "unknown"}"); + 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); @@ -302,36 +312,36 @@ public async Task CalculateRouteToCoordinatesAsync( destinationName, profile); return await TryHostedAsync(direct, fromLat, fromLon, toLat, toLon, destinationName, - profile, null, [], "ad-hoc-coordinates"); + 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) + 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, [], targetAssociation); + 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, Guid? savedProfileId, - IReadOnlyList anchors, string targetAssociation) + double toLat, double toLon, string destinationName, string profile, + HostedTripTargetAuthority? tripAuthority, HostedRouteTargetOwner targetOwner) { var generation = Interlocked.Increment(ref _hostedRoutingGeneration); CancelHostedRouting(incrementGeneration: false); if (profile == "direct") { - _hostedContext = null; _hostedRouting.SelectDirect(generation); return direct; } _hostedRoutingCancellation = new CancellationTokenSource(); var context = CreateHostedContext(fromLat, fromLon, toLat, toLon, destinationName, profile, - generation, savedProfileId, anchors, targetAssociation); - _hostedContext = context; + generation, tripAuthority, targetOwner.Association); + _hostedRequest = context; + _hostedTargetOwner = targetOwner; var result = await _hostedRouting.RequestRouteAsync(context, cancellationToken: _hostedRoutingCancellation.Token); if (result.Outcome == HostedRoutingOutcome.RequiresChoice && result.Choices is { Count: > 0 }) { @@ -344,43 +354,72 @@ private async Task TryHostedAsync(NavigationRoute direct, doubl _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); return direct; } - if (_hostedRoutingGeneration != generation || _hostedContext?.Generation != generation) return direct; + if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation) return direct; result = await _hostedRouting.RequestRouteAsync(context, result.Choices[index], _hostedRoutingCancellation.Token); } if (result.Outcome != HostedRoutingOutcome.Success || result.Candidate == null) return direct; - if (_hostedRoutingGeneration != generation || _hostedContext?.Generation != generation) return direct; - _hostedContext = result.Candidate.Context; + if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation) return direct; await MainThread.InvokeOnMainThreadAsync(() => { - var live = CreateLiveContext(); + 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, Guid? savedProfileId, - IReadOnlyList anchors, string targetAssociation) + double toLon, string destinationName, string profile, long generation, + HostedTripTargetAuthority? tripAuthority, string targetAssociation) { - var mode = profile switch { "foot" => "walk", "car" => "drive", "bike" => "bicycle", _ => profile }; + var mode = NormalizeMode(tripAuthority?.ModeKey ?? profile); + var category = NormalizeMode(tripAuthority?.Category ?? mode); var server = NormalizeServer(_settings.ServerUrl); - var sessionAuthority = _settings.ApiToken ?? string.Empty; - return new(savedProfileId, mode, mode, new(fromLon, fromLat), new(toLon, toLat), anchors, destinationName, - generation, sessionAuthority, server, targetAssociation, "hosted"); + return new(tripAuthority?.SavedTransportProfileId, mode, category, + new(fromLon, fromLat), new(toLon, toLat), tripAuthority?.Anchors ?? [], destinationName, + generation, _settings.AuthenticationSessionRevision, server, targetAssociation, "hosted", + tripAuthority?.SegmentId); } - private HostedRouteRequestContext? CreateLiveContext() + private HostedRouteLiveAuthority? CreateLiveAuthority() { - if (_hostedContext == null) return null; - var server = NormalizeServer(_settings.ServerUrl); - return _hostedContext with + 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) { - Generation = _hostedRoutingGeneration, - SessionAuthority = _settings.ApiToken ?? string.Empty, - NormalizedServer = server - }; + 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; @@ -388,37 +427,33 @@ private static string NormalizeServer(string? value) return $"{authority}{uri.AbsolutePath}".TrimEnd('/'); } - private TripSegment? FindCurrentSegment(Guid destinationId, double latitude, double longitude) => - _tripState.LoadedTrip?.Segments.Where(item => item.DestinationId == destinationId) - .Select(item => (Segment: item, Origin: _tripState.LoadedTrip.AllPlaces - .SingleOrDefault(place => place.Id == item.OriginId))) - .Where(item => item.Origin != null) - .OrderBy(item => Core.Algorithms.GeoMath.CalculateDistance(latitude, longitude, - item.Origin!.Latitude, item.Origin.Longitude)) - .Select(item => item.Segment).FirstOrDefault(); - - private IReadOnlyList ResolveAnchors(TripSegment? segment) - { - if (segment?.Waypoints.Count is not (> 0 and <= 3) || _tripState.LoadedTrip == null) return []; - var places = _tripState.LoadedTrip.AllPlaces.ToDictionary(item => item.Id); - var result = new List(segment.Waypoints.Count); - foreach (var waypoint in segment.Waypoints.OrderBy(item => item.Position)) - { - if (!places.TryGetValue(waypoint.PlaceId, out var place)) return []; - result.Add(new(place.Longitude, place.Latitude)); - } - return result; - } - private void CancelHostedRouting(bool incrementGeneration = true) { if (incrementGeneration) _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); - if (incrementGeneration) _hostedContext = null; + _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(); + } + /// /// Starts navigation with a pre-calculated route (for non-trip navigation). /// diff --git a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs index 7ae6945..41ba4fb 100644 --- a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs +++ b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs @@ -17,8 +17,29 @@ public class MockSettingsService : ISettingsService public bool IsFirstRun { get; set; } = false; public bool TimelineTrackingEnabled { get; set; } = false; public bool BackgroundTrackingEnabled { get; set; } = false; - public string? ServerUrl { get; set; } = "https://test.example.com"; - public string? ApiToken { get; set; } = "test-token"; + private string? _serverUrl = "https://test.example.com"; + private string? _apiToken = "test-token"; + public string? ServerUrl + { + get => _serverUrl; + set + { + if (string.Equals(_serverUrl, value, StringComparison.Ordinal)) return; + _serverUrl = value; + AuthenticationSessionRevision++; + } + } + public string? ApiToken + { + get => _apiToken; + set + { + if (string.Equals(_apiToken, value, StringComparison.Ordinal)) return; + _apiToken = value; + AuthenticationSessionRevision++; + } + } + public long AuthenticationSessionRevision { get; private set; } public int LocationTimeThresholdMinutes { get; set; } = 5; public int LocationDistanceThresholdMeters { get; set; } = 100; public int LocationAccuracyThresholdMeters { get; set; } = 50; diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs index 6ecb34d..9f6debd 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs @@ -86,6 +86,8 @@ private static HostedRouteCandidate Candidate() candidate.Context.TargetAssociation, candidate.Context.SegmentId, candidate.Context.SavedTransportProfileId, + candidate.Context.ModeKey, + candidate.Context.Category, candidate.SelectedProfileId, candidate.SelectedProfileAuthorityIdentity, candidate.Context.NavigationChoice); diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs index 09db2db..06137a2 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs @@ -97,12 +97,14 @@ public async Task RequestRouteAsync_UnrelatedCatalogChangeAfterCapability_DoesNo [Fact] public void Publication_SelectedAuthorityChangeBeforePublication_DiscardsCandidate() { - var context = HostedRouteRequestContext.ForTest(WalkingProfile) with - { SelectedTransportProfileId = WalkingProfile, SelectedProfileAuthorityIdentity = IdentityA }; + var context = HostedRouteRequestContext.ForTest(WalkingProfile); var candidate = new HostedRouteCandidate(new WayfarerMobile.Core.Models.NavigationRoute(), context, WalkingProfile, IdentityA, new("geoapify", CyclingProfile, "mapping", "persistent"), DateTimeOffset.UtcNow); - var live = context with { SelectedProfileAuthorityIdentity = IdentityB }; + var live = new HostedRouteLiveAuthority(context.Generation, context.AuthenticationSessionRevision, + context.NormalizedServer, context.Origin, context.Destination, context.Anchors, + context.TargetAssociation, context.SegmentId, context.SavedTransportProfileId, + context.ModeKey, context.Category, WalkingProfile, IdentityB, context.NavigationChoice); HostedRoutePublication.Current(candidate, live).Should().BeFalse(); } From 1d06dd7c0bd63bd21933c4f40641c7fb5a304009 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 23:38:06 +0300 Subject: [PATCH 15/18] WIP: prove chooser authority and coordinator publication (checkpoint; tests failing) --- tests/WayfarerMobile.Tests/GlobalUsings.cs | 1 + .../Infrastructure/MauiShareStubs.cs | 14 +- .../NavigationCoordinatorTestStubs.cs | 14 ++ .../Services/HostedRoutingServiceTests.cs | 12 -- ...NavigationCoordinatorHostedRoutingTests.cs | 160 ++++++++++++++++++ .../WayfarerMobile.Tests.csproj | 4 + 6 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 tests/WayfarerMobile.Tests/Infrastructure/NavigationCoordinatorTestStubs.cs create mode 100644 tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs diff --git a/tests/WayfarerMobile.Tests/GlobalUsings.cs b/tests/WayfarerMobile.Tests/GlobalUsings.cs index 0675ab3..02b1f40 100644 --- a/tests/WayfarerMobile.Tests/GlobalUsings.cs +++ b/tests/WayfarerMobile.Tests/GlobalUsings.cs @@ -1,6 +1,7 @@ global using Xunit; global using FluentAssertions; global using Moq; +global using Microsoft.Maui.ApplicationModel; global using WayfarerMobile.Core.Algorithms; global using WayfarerMobile.Core.Enums; global using WayfarerMobile.Core.Helpers; diff --git a/tests/WayfarerMobile.Tests/Infrastructure/MauiShareStubs.cs b/tests/WayfarerMobile.Tests/Infrastructure/MauiShareStubs.cs index f8c84fc..94b942b 100644 --- a/tests/WayfarerMobile.Tests/Infrastructure/MauiShareStubs.cs +++ b/tests/WayfarerMobile.Tests/Infrastructure/MauiShareStubs.cs @@ -29,4 +29,16 @@ public interface IConnectivity event EventHandler? ConnectivityChanged; } public static class Connectivity { public static IConnectivity Current { get; set; } = new ConnectivityStub(); private sealed class ConnectivityStub : IConnectivity { public NetworkAccess NetworkAccess => NetworkAccess.Internet; public event EventHandler? ConnectivityChanged; } } -public static class MainThread { public static void BeginInvokeOnMainThread(Action action) => action(); } +namespace Microsoft.Maui.ApplicationModel +{ + public static class MainThread + { + public static void BeginInvokeOnMainThread(Action action) => action(); + + public static Task InvokeOnMainThreadAsync(Action action) + { + action(); + return Task.CompletedTask; + } + } +} diff --git a/tests/WayfarerMobile.Tests/Infrastructure/NavigationCoordinatorTestStubs.cs b/tests/WayfarerMobile.Tests/Infrastructure/NavigationCoordinatorTestStubs.cs new file mode 100644 index 0000000..8ffb158 --- /dev/null +++ b/tests/WayfarerMobile.Tests/Infrastructure/NavigationCoordinatorTestStubs.cs @@ -0,0 +1,14 @@ +using WayfarerMobile.Core.Models; + +namespace WayfarerMobile.ViewModels; + +public sealed class NavigationHudViewModel : IDisposable +{ + public event EventHandler? StopNavigationRequested; + + public Task StartNavigationAsync(NavigationRoute route) => Task.CompletedTask; + + public void StopNavigationDisplay() { } + + public void Dispose() { } +} diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs index 06137a2..3d18331 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs @@ -31,18 +31,6 @@ public void SelectProfile_UsesGuidThenOnlyAnUnambiguousTextualHint( result.Profile?.TransportProfileId.Should().Be(WalkingProfile); } - [Fact] - public void ConfirmChoice_AcceptsSameGuidAndRefreshesRenamedMetadata() - { - var original = Catalog(new HostedRoutingProfile(WalkingProfile, "Walking", "walk", "active")); - var renamed = new HostedRoutingCatalog(IdentityB, "available", - [new(WalkingProfile, "On foot", "walk", "active")]); - - HostedProfileSelector.Confirm(null, original).Should().BeNull(); - HostedProfileSelector.Confirm(new(WalkingProfile, "Walking", "walk", "active"), renamed) - .Should().Be(renamed.Profiles[0]); - } - [Theory] [InlineData("v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", true)] [InlineData("v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", false)] diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs new file mode 100644 index 0000000..37f34ab --- /dev/null +++ b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs @@ -0,0 +1,160 @@ +using Microsoft.Extensions.Logging.Abstractions; +using WayfarerMobile.Services; +using WayfarerMobile.Tests.Infrastructure.Mocks; +using WayfarerMobile.ViewModels; + +namespace WayfarerMobile.Tests.Unit.ViewModels; + +public sealed class NavigationCoordinatorHostedRoutingTests +{ + private static readonly Guid WalkingProfile = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid HikingProfile = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private const string IdentityA = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + private const string IdentityB = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ"; + + [Fact] + public async Task OpenChooser_CatalogChanges_SubmitsDisplayedIdentityThenRefreshesBeforeReselection() + { + var catalogA = Catalog(IdentityA, + new(WalkingProfile, "Walking", "walk", "active"), + new(HikingProfile, "Hiking", "walk", "outdoors")); + var catalogB = Catalog(IdentityB, + new(WalkingProfile, "On foot", "walk", "active"), + new(HikingProfile, "Trail", "walk", "outdoors")); + var api = new Mock(MockBehavior.Strict); + api.SetupSequence(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(catalogA) + .ReturnsAsync(catalogB); + api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityA, It.IsAny())) + .ReturnsAsync(new HostedRoutingCapability("catalog-changed", WalkingProfile, + null, null, null, null, null, null, null)); + api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityB, It.IsAny())) + .ReturnsAsync(HostedRoutingCapability.Available( + WalkingProfile, IdentityB, IdentityB, Attribution())); + api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityB)); + var presentations = new List>(); + var dialogs = new Mock(MockBehavior.Strict); + dialogs.Setup(service => service.SelectAsync("Wayfarer routing profile", + It.IsAny>(), "Direct")) + .Callback, string>((_, choices, _) => presentations.Add(choices)) + .ReturnsAsync(() => presentations.Count == 1 + ? $"Walking — walk ({WalkingProfile:D})" + : null); + var (coordinator, navigation, _, callbacks) = CreateCoordinator(api.Object, dialogs.Object); + callbacks.SetupGet(value => value.CurrentLocation).Returns(new LocationData { Latitude = 37, Longitude = 23 }); + + var route = await coordinator.CalculateRouteToCoordinatesAsync(37, 23, 37.01, 23.01, "Target", "foot"); + + route.Should().BeSameAs(navigation.ActiveRoute); + route.IsDirectRoute.Should().BeTrue(); + route.HostedProvenance.Should().BeNull(); + presentations.Should().HaveCount(2); + presentations[0].Should().ContainSingle(choice => choice.StartsWith("Walking —", StringComparison.Ordinal)); + presentations[1].Should().ContainSingle(choice => choice.StartsWith("On foot —", StringComparison.Ordinal)); + api.Verify(client => client.GetCapabilityAsync(WalkingProfile, IdentityA, + It.IsAny()), Times.Once); + api.Verify(client => client.GetCapabilityAsync(It.IsAny(), IdentityB, + It.IsAny()), Times.Never); + api.Verify(client => client.GetRouteAsync(It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task DelayedHostedResponse_CurrentLocationChanges_DoesNotPublishToActiveDirectRoute() + { + var routeResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var routeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var api = SuccessfulApi(); + api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) + .Returns(() => + { + routeStarted.SetResult(); + return routeResponse.Task; + }); + var dialogs = Mock.Of(); + var (coordinator, navigation, _, callbacks) = CreateCoordinator(api.Object, dialogs); + var location = new LocationData { Latitude = 37, Longitude = 23 }; + callbacks.SetupGet(value => value.CurrentLocation).Returns(() => location); + + var pending = coordinator.CalculateRouteToCoordinatesAsync(37, 23, 37.01, 23.01, "Target", "foot"); + await routeStarted.Task; + location = new LocationData { Latitude = 37.1, Longitude = 23.1 }; + routeResponse.SetResult(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA)); + var route = await pending; + + route.Should().BeSameAs(navigation.ActiveRoute); + route.IsDirectRoute.Should().BeTrue(); + route.Waypoints.Should().HaveCount(2); + route.Attribution.Should().BeEmpty(); + route.HostedProvenance.Should().BeNull(); + } + + [Fact] + public async Task CurrentHostedResponse_PublishesToActiveRouteAndDirectReplacementClearsProvenance() + { + var api = SuccessfulApi(); + var (coordinator, navigation, _, callbacks) = CreateCoordinator(api.Object, Mock.Of()); + callbacks.SetupGet(value => value.CurrentLocation).Returns(new LocationData { Latitude = 37, Longitude = 23 }); + + var hosted = await coordinator.CalculateRouteToCoordinatesAsync(37, 23, 37.01, 23.01, "Target", "foot"); + + hosted.Should().BeSameAs(navigation.ActiveRoute); + hosted.IsDirectRoute.Should().BeFalse(); + hosted.Attribution.Should().ContainSingle(item => item.Text == "Powered by Wayfarer test"); + hosted.HostedProvenance.Should().NotBeNull(); + hosted.HostedProvenance!.TransportProfileId.Should().Be(WalkingProfile); + + var direct = await coordinator.CalculateRouteToCoordinatesAsync(37, 23, 37.02, 23.02, "Direct", "direct"); + + direct.Should().BeSameAs(navigation.ActiveRoute); + direct.Should().NotBeSameAs(hosted); + direct.IsDirectRoute.Should().BeTrue(); + direct.Attribution.Should().BeEmpty(); + direct.HostedProvenance.Should().BeNull(); + } + + private static (NavigationCoordinatorViewModel Coordinator, TripNavigationService Navigation, + MockSettingsService Settings, Mock Callbacks) CreateCoordinator( + IHostedRoutingApiClient api, IDialogService dialogs) + { + var state = new MockTripStateManager(); + var navigation = new TripNavigationService( + NullLogger.Instance, + Mock.Of(), + new NavigationRouteBuilder(NullLogger.Instance), + state); + var settings = new MockSettingsService(); + var coordinator = new NavigationCoordinatorViewModel( + navigation, + new NavigationHudViewModel(), + Mock.Of(), + new HostedRoutingService(api, NullLogger.Instance), + settings, + dialogs, + state, + NullLogger.Instance); + var callbacks = new Mock(); + coordinator.SetCallbacks(callbacks.Object); + return (coordinator, navigation, settings, callbacks); + } + + private static Mock SuccessfulApi() + { + var api = new Mock(); + api.Setup(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(Catalog(IdentityA, + new HostedRoutingProfile(WalkingProfile, "Walking", "walk", "active"))); + api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityA, It.IsAny())) + .ReturnsAsync(HostedRoutingCapability.Available(WalkingProfile, IdentityA, IdentityA, Attribution())); + api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA)); + return api; + } + + private static HostedRoutingCatalog Catalog(string identity, params HostedRoutingProfile[] profiles) => + new(identity, "available", profiles); + + private static IReadOnlyList Attribution() => + [new("Powered by Wayfarer test", "https://example.test")]; +} diff --git a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj index 7053c9d..7d66c23 100644 --- a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj +++ b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj @@ -49,6 +49,7 @@ + @@ -80,6 +81,9 @@ + + + From 5ae4bc85fae860a32f7e689569a908c4cd073b17 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 23:40:29 +0300 Subject: [PATCH 16/18] fix: preserve displayed routing catalog authority --- docs/12-Services.md | 5 ++ docs/13-API.md | 5 ++ .../Services/HostedRoutingModels.cs | 10 +-- .../Services/HostedRoutingService.cs | 64 +++++++++++++------ .../NavigationCoordinatorViewModel.cs | 23 ++++++- .../NavigationCoordinatorTestStubs.cs | 6 +- 6 files changed, 83 insertions(+), 30 deletions(-) diff --git a/docs/12-Services.md b/docs/12-Services.md index ecf4af0..8c1be97 100644 --- a/docs/12-Services.md +++ b/docs/12-Services.md @@ -561,6 +561,11 @@ unavailability remain routing-local and retain Direct guidance without affecting 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 diff --git a/docs/13-API.md b/docs/13-API.md index 29b9540..c059d5c 100644 --- a/docs/13-API.md +++ b/docs/13-API.md @@ -486,6 +486,11 @@ Wayfarer server, provider credentials stay server-side, and returned attribution 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. diff --git a/src/WayfarerMobile/Services/HostedRoutingModels.cs b/src/WayfarerMobile/Services/HostedRoutingModels.cs index 8bb03c5..b5c0535 100644 --- a/src/WayfarerMobile/Services/HostedRoutingModels.cs +++ b/src/WayfarerMobile/Services/HostedRoutingModels.cs @@ -62,19 +62,15 @@ public static HostedProfileSelection Select(Guid? savedProfileId, string? modeKe : new(HostedProfileSelectionKind.RequiresChoice, null, catalog.Profiles); } - public static HostedRoutingProfile? Confirm(HostedRoutingProfile? choice, HostedRoutingCatalog currentCatalog) => - choice != null && currentCatalog.DiscoveryCatalogIdentity != null - ? currentCatalog.Profiles.SingleOrDefault(item => item.TransportProfileId == choice.TransportProfileId) - : null; - 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, InvalidResponse, Stale, Cancelled } +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); + IReadOnlyList? Choices = null, HostedRouteCandidate? Candidate = null, + string? DiscoveryCatalogIdentity = null); public sealed record HostedRouteCapabilityMetadata(string Provider, Guid ProviderConfigurationId, string MappingIdentity, string StorageMode); diff --git a/src/WayfarerMobile/Services/HostedRoutingService.cs b/src/WayfarerMobile/Services/HostedRoutingService.cs index 18a2cd7..8443b9d 100644 --- a/src/WayfarerMobile/Services/HostedRoutingService.cs +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -32,33 +32,46 @@ public async Task RequestRouteAsync(HostedRouteRequestConte if (!Begin(context)) return new(HostedRoutingOutcome.Stale); try { - var catalog = await api.DiscoverAsync(cancellationToken); - if (!AvailableCatalog(catalog)) return new(HostedRoutingOutcome.Unavailable); - if (context.ExpectedCatalogIdentity != null - && context.ExpectedCatalogIdentity != catalog.DiscoveryCatalogIdentity) - return new(HostedRoutingOutcome.Stale); + 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 selection = explicitChoice == null - ? HostedProfileSelector.Select(context.SavedTransportProfileId, context.ModeKey, context.Category, catalog) - : new HostedProfileSelection(HostedProfileSelectionKind.Selected, - HostedProfileSelector.Confirm(explicitChoice, catalog), catalog.Profiles); - if (selection.Profile == null) - return new(HostedRoutingOutcome.RequiresChoice, Choices: selection.Choices); - var capability = await api.GetCapabilityAsync(selection.Profile.TransportProfileId, - catalog.DiscoveryCatalogIdentity!, cancellationToken); - if (!ValidCapability(capability, selection.Profile.TransportProfileId, catalog.DiscoveryCatalogIdentity!)) - return new(capability.Outcome == "catalog-changed" ? HostedRoutingOutcome.Stale : HostedRoutingOutcome.Unavailable); - var request = new HostedRouteRequest(selection.Profile.TransportProfileId, context.Origin, + var capability = await api.GetCapabilityAsync( + selectedProfile.TransportProfileId, catalogIdentity, cancellationToken); + if (capability.Outcome == "catalog-changed") + return await RefreshCatalogAsync(cancellationToken); + 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, selection.Profile.TransportProfileId, + 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, - selection.Profile.TransportProfileId, capability.SelectedProfileAuthorityIdentity!, metadata, + selectedProfile.TransportProfileId, capability.SelectedProfileAuthorityIdentity!, metadata, response.GeneratedAt!.Value); return new(HostedRoutingOutcome.Success, Candidate: candidate); } @@ -110,11 +123,22 @@ private bool SelectCurrent(long generation, Guid profileId, string authorityIden } } + 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(item => item.TransportProfileId != Guid.Empty && Bounded(item.DisplayName, 200) - && Bounded(item.ModeKey, 100) && Bounded(item.Category, 100)); + && 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 diff --git a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs index 9cb9e92..6f10e89 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -343,8 +343,19 @@ private async Task TryHostedAsync(NavigationRoute direct, doubl _hostedRequest = context; _hostedTargetOwner = targetOwner; var result = await _hostedRouting.RequestRouteAsync(context, cancellationToken: _hostedRoutingCancellation.Token); - if (result.Outcome == HostedRoutingOutcome.RequiresChoice && result.Choices is { Count: > 0 }) + 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"); @@ -355,7 +366,15 @@ private async Task TryHostedAsync(NavigationRoute direct, doubl return direct; } if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation) return direct; - result = await _hostedRouting.RequestRouteAsync(context, result.Choices[index], _hostedRoutingCancellation.Token); + var choiceContext = context with { ExpectedCatalogIdentity = result.DiscoveryCatalogIdentity }; + _hostedRequest = choiceContext; + result = await _hostedRouting.RequestRouteAsync( + choiceContext, result.Choices[index], _hostedRoutingCancellation.Token); + } + 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; diff --git a/tests/WayfarerMobile.Tests/Infrastructure/NavigationCoordinatorTestStubs.cs b/tests/WayfarerMobile.Tests/Infrastructure/NavigationCoordinatorTestStubs.cs index 8ffb158..bf7dea8 100644 --- a/tests/WayfarerMobile.Tests/Infrastructure/NavigationCoordinatorTestStubs.cs +++ b/tests/WayfarerMobile.Tests/Infrastructure/NavigationCoordinatorTestStubs.cs @@ -4,7 +4,11 @@ namespace WayfarerMobile.ViewModels; public sealed class NavigationHudViewModel : IDisposable { - public event EventHandler? StopNavigationRequested; + public event EventHandler? StopNavigationRequested + { + add { } + remove { } + } public Task StartNavigationAsync(NavigationRoute route) => Task.CompletedTask; From 8f3bfa30a3fe7b90598227c515c56f44b7db3a3b Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 23:57:39 +0300 Subject: [PATCH 17/18] WIP: reproduce repeated routing catalog drift (checkpoint; tests failing) --- ...NavigationCoordinatorHostedRoutingTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs index 37f34ab..b4d5224 100644 --- a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs @@ -60,6 +60,49 @@ public async Task OpenChooser_CatalogChanges_SubmitsDisplayedIdentityThenRefresh It.IsAny()), Times.Never); } + [Fact] + public async Task OpenChooser_RepeatedCatalogChange_RefreshesOnlyOnceAndRetainsDirect() + { + var catalogA = Catalog(IdentityA, + new(WalkingProfile, "Walking", "walk", "active"), + new(HikingProfile, "Hiking", "walk", "outdoors")); + var catalogB = Catalog(IdentityB, + new HostedRoutingProfile(WalkingProfile, "On foot", "walk", "active")); + var api = new Mock(MockBehavior.Strict); + api.SetupSequence(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(catalogA) + .ReturnsAsync(catalogB); + api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityA, It.IsAny())) + .ReturnsAsync(new HostedRoutingCapability("catalog-changed", WalkingProfile, + null, null, null, null, null, null, null)); + api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityB, It.IsAny())) + .ReturnsAsync(new HostedRoutingCapability("catalog-changed", WalkingProfile, + null, null, null, null, null, null, null)); + var presentations = new List>(); + var dialogs = new Mock(MockBehavior.Strict); + dialogs.Setup(service => service.SelectAsync("Wayfarer routing profile", + It.IsAny>(), "Direct")) + .Callback, string>((_, choices, _) => presentations.Add(choices)) + .ReturnsAsync(() => presentations.Count == 1 + ? $"Walking — walk ({WalkingProfile:D})" + : $"On foot — walk ({WalkingProfile:D})"); + var (coordinator, navigation, _, callbacks) = CreateCoordinator(api.Object, dialogs.Object); + callbacks.SetupGet(value => value.CurrentLocation).Returns(new LocationData { Latitude = 37, Longitude = 23 }); + + var route = await coordinator.CalculateRouteToCoordinatesAsync(37, 23, 37.01, 23.01, "Target", "foot"); + + route.Should().BeSameAs(navigation.ActiveRoute); + route.IsDirectRoute.Should().BeTrue(); + presentations.Should().HaveCount(2); + api.Verify(client => client.DiscoverAsync(It.IsAny()), Times.Exactly(2)); + api.Verify(client => client.GetCapabilityAsync(WalkingProfile, IdentityA, + It.IsAny()), Times.Once); + api.Verify(client => client.GetCapabilityAsync(WalkingProfile, IdentityB, + It.IsAny()), Times.Once); + api.Verify(client => client.GetRouteAsync(It.IsAny(), + It.IsAny()), Times.Never); + } + [Fact] public async Task DelayedHostedResponse_CurrentLocationChanges_DoesNotPublishToActiveDirectRoute() { From e0646e36b4e10aefddd4148fbb6b19a4613d6392 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 30 Aug 2026 23:58:45 +0300 Subject: [PATCH 18/18] fix: bound hosted catalog rediscovery --- src/WayfarerMobile/Services/HostedRoutingService.cs | 7 +++++-- .../ViewModels/NavigationCoordinatorViewModel.cs | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/WayfarerMobile/Services/HostedRoutingService.cs b/src/WayfarerMobile/Services/HostedRoutingService.cs index 8443b9d..cc04afd 100644 --- a/src/WayfarerMobile/Services/HostedRoutingService.cs +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -27,7 +27,8 @@ public HostedRoutingService(IHostedRoutingApiClient api, ILogger RequestRouteAsync(HostedRouteRequestContext context, - HostedRoutingProfile? explicitChoice = null, CancellationToken cancellationToken = default) + HostedRoutingProfile? explicitChoice = null, CancellationToken cancellationToken = default, + bool allowCatalogRediscovery = true) { if (!Begin(context)) return new(HostedRoutingOutcome.Stale); try @@ -58,7 +59,9 @@ public async Task RequestRouteAsync(HostedRouteRequestConte var capability = await api.GetCapabilityAsync( selectedProfile.TransportProfileId, catalogIdentity, cancellationToken); if (capability.Outcome == "catalog-changed") - return await RefreshCatalogAsync(cancellationToken); + 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, diff --git a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs index 6f10e89..698e56b 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -342,7 +342,9 @@ private async Task TryHostedAsync(NavigationRoute direct, doubl 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, @@ -369,7 +371,9 @@ private async Task TryHostedAsync(NavigationRoute direct, doubl var choiceContext = context with { ExpectedCatalogIdentity = result.DiscoveryCatalogIdentity }; _hostedRequest = choiceContext; result = await _hostedRouting.RequestRouteAsync( - choiceContext, result.Choices[index], _hostedRoutingCancellation.Token); + choiceContext, result.Choices[index], _hostedRoutingCancellation.Token, + catalogRediscoveryAvailable); + if (result.Outcome == HostedRoutingOutcome.CatalogChanged) catalogRediscoveryAvailable = false; } if (result.Outcome == HostedRoutingOutcome.CatalogChanged) {