From 3d5faa2e07df1d867a6ef1272e64fadca24f8953 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Thu, 3 Sep 2026 19:15:40 +0300 Subject: [PATCH 1/5] WIP: add explicit provider mode routing (checkpoint) --- .../Models/NavigationRoute.cs | 3 +- .../Entities/RetainedWayfarerRouteEntity.cs | 1 + .../RetainedWayfarerRouteRepository.cs | 3 +- .../RetainedWayfarerRouteMigration.cs | 13 +- .../Services/HostedRoutingApiClient.cs | 13 +- .../Services/HostedRoutingModels.cs | 67 ++++----- .../Services/HostedRoutingService.cs | 62 ++++---- .../NavigationCoordinatorViewModel.cs | 21 +-- .../Services/HostedRoutingApiClientTests.cs | 19 ++- .../Services/HostedRoutingServiceTests.cs | 133 +++++++++++++----- ...NavigationCoordinatorHostedRoutingTests.cs | 103 +++++++------- 11 files changed, 258 insertions(+), 180 deletions(-) diff --git a/src/WayfarerMobile.Core/Models/NavigationRoute.cs b/src/WayfarerMobile.Core/Models/NavigationRoute.cs index d8b5a38..c84e37a 100644 --- a/src/WayfarerMobile.Core/Models/NavigationRoute.cs +++ b/src/WayfarerMobile.Core/Models/NavigationRoute.cs @@ -58,7 +58,8 @@ public sealed record HostedRouteProvenance( Guid ProviderConfigurationId, string MappingIdentity, string StorageMode, - DateTimeOffset GeneratedAt) + DateTimeOffset GeneratedAt, + string? ProviderMode = null) { /// Whether this route was selected from bounded local retained storage. public bool IsRetained { get; init; } diff --git a/src/WayfarerMobile/Data/Entities/RetainedWayfarerRouteEntity.cs b/src/WayfarerMobile/Data/Entities/RetainedWayfarerRouteEntity.cs index c91406b..4ae83da 100644 --- a/src/WayfarerMobile/Data/Entities/RetainedWayfarerRouteEntity.cs +++ b/src/WayfarerMobile/Data/Entities/RetainedWayfarerRouteEntity.cs @@ -17,6 +17,7 @@ public sealed class RetainedWayfarerRouteEntity [Indexed] public string TransportProfileId { get; set; } = string.Empty; public string SelectedProfileAuthorityIdentity { get; set; } = string.Empty; + public string? ProviderMode { get; set; } public string ModeKey { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public int OriginLongitude { get; set; } diff --git a/src/WayfarerMobile/Data/Repositories/RetainedWayfarerRouteRepository.cs b/src/WayfarerMobile/Data/Repositories/RetainedWayfarerRouteRepository.cs index 5f1685d..4eea030 100644 --- a/src/WayfarerMobile/Data/Repositories/RetainedWayfarerRouteRepository.cs +++ b/src/WayfarerMobile/Data/Repositories/RetainedWayfarerRouteRepository.cs @@ -272,6 +272,7 @@ private static bool TryPrepare(HostedRouteCandidate candidate, Guid partition, MappingIdentity = candidate.Metadata.MappingIdentity, TransportProfileId = candidate.SelectedProfileId.ToString("D"), SelectedProfileAuthorityIdentity = candidate.SelectedProfileAuthorityIdentity, + ProviderMode = candidate.SelectedProviderMode, ModeKey = candidate.Context.ModeKey!, Category = candidate.Context.Category!, OriginLongitude = canonical[0], OriginLatitude = canonical[1], @@ -364,7 +365,7 @@ private static bool TryBuildRoute(RetainedWayfarerRouteEntity row, string destin EstimatedDuration = TimeSpan.FromSeconds(row.DurationSeconds), IsDirectRoute = false, Attribution = attribution.ToList(), HostedProvenance = new(profileId, row.SelectedProfileAuthorityIdentity, row.Provider, - configurationId, row.MappingIdentity, row.StorageAuthority, generated) + configurationId, row.MappingIdentity, row.StorageAuthority, generated, row.ProviderMode) { IsRetained = true, Age = age } }; return true; diff --git a/src/WayfarerMobile/Data/Services/RetainedWayfarerRouteMigration.cs b/src/WayfarerMobile/Data/Services/RetainedWayfarerRouteMigration.cs index 8d3db3b..fde9489 100644 --- a/src/WayfarerMobile/Data/Services/RetainedWayfarerRouteMigration.cs +++ b/src/WayfarerMobile/Data/Services/RetainedWayfarerRouteMigration.cs @@ -5,7 +5,7 @@ namespace WayfarerMobile.Data.Services; public static class RetainedWayfarerRouteMigration { - public const int SchemaVersion = 10; + public const int SchemaVersion = 11; public static async Task ApplyApplicationUpgradeAsync(SQLiteAsyncConnection connection, int installedVersion, Func recordSchemaVersion, @@ -13,6 +13,8 @@ public static async Task ApplyApplicationUpgradeAsync(SQLiteAsyncConnection conn { if (installedVersion >= SchemaVersion) return; await ApplyAsync(connection, cancellationToken); + if (installedVersion >= 10) + await EnsureProviderModeColumnAsync(connection, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); await recordSchemaVersion(SchemaVersion); } @@ -31,4 +33,13 @@ await connection.ExecuteAsync(@" CREATE INDEX IF NOT EXISTS IX_RetainedWayfarerRoutes_Eviction ON RetainedWayfarerRoutes (LastUsedAtUnixMilliseconds, StoredAtUnixMilliseconds, Id)"); } + + private static async Task EnsureProviderModeColumnAsync(SQLiteAsyncConnection connection, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var columns = await connection.GetTableInfoAsync("RetainedWayfarerRoutes"); + if (columns.All(column => column.Name != nameof(RetainedWayfarerRouteEntity.ProviderMode))) + await connection.ExecuteAsync("ALTER TABLE RetainedWayfarerRoutes ADD COLUMN ProviderMode TEXT NULL"); + } } diff --git a/src/WayfarerMobile/Services/HostedRoutingApiClient.cs b/src/WayfarerMobile/Services/HostedRoutingApiClient.cs index 54fc1aa..0303dc6 100644 --- a/src/WayfarerMobile/Services/HostedRoutingApiClient.cs +++ b/src/WayfarerMobile/Services/HostedRoutingApiClient.cs @@ -31,10 +31,11 @@ public async Task DiscoverAsync(CancellationToken cancella ?? new(null, "invalid-response", []); } - public async Task GetCapabilityAsync(Guid profileId, + public async Task GetCapabilityAsync(Guid profileId, string providerMode, string discoveryCatalogIdentity, CancellationToken cancellationToken) { - var endpoint = $"/api/mobile/routing/capability/{profileId:D}?discoveryCatalogIdentity={Uri.EscapeDataString(discoveryCatalogIdentity)}"; + var endpoint = $"/api/mobile/routing/capability/{profileId:D}?discoveryCatalogIdentity={Uri.EscapeDataString(discoveryCatalogIdentity)}" + + $"&providerMode={Uri.EscapeDataString(providerMode)}"; using var response = await SendAsync(HttpMethod.Get, endpoint, null, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) return new("unavailable", profileId, null, null, null, null, null, null, null); @@ -89,11 +90,11 @@ private static HostedRouteResponse Failure(string outcome) => private sealed record CapabilityDto(string Outcome, Guid TransportProfileId, string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity, string? StorageMode, IReadOnlyList? Attribution, string? DiscoveryCatalogIdentity, - string? SelectedProfileAuthorityIdentity) + string? SelectedProfileAuthorityIdentity, string? ProviderMode) { public HostedRoutingCapability ToModel() => new(Outcome, TransportProfileId, Provider, ProviderConfigurationId, MappingIdentity, StorageMode, Attribution, DiscoveryCatalogIdentity, - SelectedProfileAuthorityIdentity); + SelectedProfileAuthorityIdentity, ProviderMode); } private sealed record RouteResponseDto(bool Succeeded, string Outcome, IReadOnlyList? Geometry, @@ -101,12 +102,12 @@ private sealed record RouteResponseDto(bool Succeeded, string Outcome, IReadOnly string? GeneratedAt, string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity, Guid? TransportProfileId, IReadOnlyList? MatchPoints, IReadOnlyList? Attribution, string? StorageMode, - string? SelectedProfileAuthorityIdentity) + string? SelectedProfileAuthorityIdentity, string? ProviderMode) { public HostedRouteResponse ToModel() => new(Succeeded, Outcome, Geometry, DistanceMetres, DurationSeconds, Instructions, ParseGeneratedAt(GeneratedAt), Provider, ProviderConfigurationId, MappingIdentity, TransportProfileId, MatchPoints, Attribution, StorageMode, - SelectedProfileAuthorityIdentity); + SelectedProfileAuthorityIdentity, ProviderMode); private static DateTimeOffset? ParseGeneratedAt(string? value) { diff --git a/src/WayfarerMobile/Services/HostedRoutingModels.cs b/src/WayfarerMobile/Services/HostedRoutingModels.cs index 5b8f5cc..6360032 100644 --- a/src/WayfarerMobile/Services/HostedRoutingModels.cs +++ b/src/WayfarerMobile/Services/HostedRoutingModels.cs @@ -4,7 +4,13 @@ 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 HostedProviderMode(string Key, string Label); +public sealed record HostedRoutingCatalog(string? DiscoveryCatalogIdentity, string Outcome, + IReadOnlyList Profiles, string? Provider = null, + IReadOnlyList? ProviderModes = null) +{ + public IReadOnlyList Modes => ProviderModes ?? []; +} public sealed record HostedRouteCoordinate(double Longitude, double Latitude); public sealed record HostedRouteInstruction(string Text, string Type, int FromIndex, int ToIndex, double DistanceMetres, double DurationSeconds); @@ -12,78 +18,53 @@ public sealed record HostedRouteInstruction(string Text, string Type, int FromIn public sealed record HostedRoutingCapability(string Outcome, Guid TransportProfileId, string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity, string? StorageMode, IReadOnlyList? Attribution, string? DiscoveryCatalogIdentity, - string? SelectedProfileAuthorityIdentity) + string? SelectedProfileAuthorityIdentity, string? ProviderMode = null) { public static HostedRoutingCapability Available(Guid profileId, string catalogIdentity, string selectedAuthorityIdentity, IReadOnlyList attribution, + string providerMode = "walk", 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); + mappingIdentity, storageMode, attribution, catalogIdentity, selectedAuthorityIdentity, providerMode); } public sealed record HostedRouteRequest(Guid TransportProfileId, HostedRouteCoordinate Origin, HostedRouteCoordinate Destination, IReadOnlyList Anchors, - string SelectedProfileAuthorityIdentity); + string SelectedProfileAuthorityIdentity, string ProviderMode = "walk"); public sealed record HostedRouteResponse(bool Succeeded, string Outcome, IReadOnlyList? Geometry, double? DistanceMetres, double? DurationSeconds, IReadOnlyList? Instructions, DateTimeOffset? GeneratedAt, string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity, Guid? TransportProfileId, IReadOnlyList? MatchPoints, IReadOnlyList? Attribution, string? StorageMode, - string? SelectedProfileAuthorityIdentity) + string? SelectedProfileAuthorityIdentity, string? ProviderMode = null) { public static HostedRouteResponse ValidForTest(Guid profileId, string selectedAuthorityIdentity) => new( true, "available", [new(23, 37), new(23.01, 37.01)], 1500, 900, [new("Continue", "continue", 0, 1, 1500, 900)], DateTimeOffset.UtcNow, "geoapify", Guid.Parse("22222222-2222-2222-2222-222222222222"), "mapping", profileId, [new(23, 37), new(23.01, 37.01)], [new("Powered by Wayfarer test", "https://example.test")], - "persistent", selectedAuthorityIdentity); -} - -public enum HostedProfileSelectionKind { Selected, RequiresChoice } -public sealed record HostedProfileSelection(HostedProfileSelectionKind Kind, HostedRoutingProfile? Profile, - IReadOnlyList Choices); - -public static class HostedProfileSelector -{ - public static HostedProfileSelection Select(Guid? savedProfileId, string? modeKey, string? category, - HostedRoutingCatalog catalog) - { - if (savedProfileId is { } id) - { - var exact = catalog.Profiles.SingleOrDefault(item => item.TransportProfileId == id); - if (exact != null) return new(HostedProfileSelectionKind.Selected, exact, catalog.Profiles); - } - - var matches = catalog.Profiles.Where(item => TextMatches(item, modeKey, category)).ToArray(); - return matches.Length == 1 - ? new(HostedProfileSelectionKind.Selected, matches[0], catalog.Profiles) - : new(HostedProfileSelectionKind.RequiresChoice, null, catalog.Profiles); - } - - private static bool TextMatches(HostedRoutingProfile item, string? modeKey, string? category) => - (!string.IsNullOrWhiteSpace(modeKey) && string.Equals(item.ModeKey, modeKey, StringComparison.OrdinalIgnoreCase)) - || (!string.IsNullOrWhiteSpace(category) && string.Equals(item.Category, category, StringComparison.OrdinalIgnoreCase)); + "persistent", selectedAuthorityIdentity, "walk"); } public enum HostedRoutingOutcome { Success, Unavailable, RequiresChoice, CatalogChanged, InvalidResponse, Stale, Cancelled } public sealed record HostedRoutingResult(HostedRoutingOutcome Outcome, NavigationRoute? Route = null, - IReadOnlyList? Choices = null, HostedRouteCandidate? Candidate = null, - string? DiscoveryCatalogIdentity = null); + IReadOnlyList? Choices = null, HostedRouteCandidate? Candidate = null, + string? DiscoveryCatalogIdentity = null, string? Provider = null); public sealed record HostedRouteCapabilityMetadata(string Provider, Guid ProviderConfigurationId, string MappingIdentity, string StorageMode); public sealed record HostedRouteCandidate(NavigationRoute Route, HostedRouteRequestContext Context, - Guid SelectedProfileId, string SelectedProfileAuthorityIdentity, HostedRouteCapabilityMetadata Metadata, - DateTimeOffset GeneratedAt); + Guid SelectedProfileId, string SelectedProfileAuthorityIdentity, + HostedRouteCapabilityMetadata Metadata, DateTimeOffset GeneratedAt, string SelectedProviderMode = "walk"); public sealed record HostedRouteRequestContext(Guid? SavedTransportProfileId, string? ModeKey, string? Category, HostedRouteCoordinate Origin, HostedRouteCoordinate Destination, IReadOnlyList Anchors, string DestinationName, long Generation, long AuthenticationSessionRevision, string NormalizedServer, string TargetAssociation, string NavigationChoice, Guid? SegmentId = null, - string? ExpectedCatalogIdentity = null) + string? ExpectedCatalogIdentity = null, string? ExpectedProvider = null) { public static HostedRouteRequestContext ForTest(Guid profileId, string? expectedCatalogIdentity = null) => new( profileId, "walk", "active", new(23, 37), new(23.01, 37.01), [], "Target", 1, @@ -104,10 +85,11 @@ public sealed record HostedRouteLiveAuthority( string? Category, Guid? SelectedTransportProfileId, string? SelectedProfileAuthorityIdentity, - string NavigationChoice); + string NavigationChoice, + string? SelectedProviderMode = null); public sealed record HostedRouteSelection(long Generation, Guid TransportProfileId, - string SelectedProfileAuthorityIdentity); + string ProviderMode, string SelectedProfileAuthorityIdentity); public sealed record HostedTripTargetAuthority( Guid DestinationPlaceId, @@ -222,6 +204,7 @@ public static bool Current(HostedRouteCandidate candidate, HostedRouteLiveAuthor var expected = candidate.Context; return CurrentRequest(expected, live) && live.SelectedTransportProfileId == candidate.SelectedProfileId + && live.SelectedProviderMode == candidate.SelectedProviderMode && live.SelectedProfileAuthorityIdentity == candidate.SelectedProfileAuthorityIdentity; } @@ -254,8 +237,7 @@ private static void Copy(HostedRouteCandidate candidate, NavigationRoute target) candidate.Metadata.Provider, candidate.Metadata.ProviderConfigurationId, candidate.Metadata.MappingIdentity, - candidate.Metadata.StorageMode, - generated); + candidate.Metadata.StorageMode, generated, candidate.SelectedProviderMode); } private static void CopyRoute(NavigationRoute source, NavigationRoute target) @@ -275,7 +257,8 @@ private static void CopyRoute(NavigationRoute source, NavigationRoute target) public interface IHostedRoutingApiClient { Task DiscoverAsync(CancellationToken cancellationToken); - Task GetCapabilityAsync(Guid profileId, string discoveryCatalogIdentity, + Task GetCapabilityAsync(Guid profileId, string providerMode, + string discoveryCatalogIdentity, CancellationToken cancellationToken); Task GetRouteAsync(HostedRouteRequest request, CancellationToken cancellationToken); } diff --git a/src/WayfarerMobile/Services/HostedRoutingService.cs b/src/WayfarerMobile/Services/HostedRoutingService.cs index 1b36bd9..eebd6b1 100644 --- a/src/WayfarerMobile/Services/HostedRoutingService.cs +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -27,55 +27,53 @@ public HostedRoutingService(IHostedRoutingApiClient api, ILogger RequestRouteAsync(HostedRouteRequestContext context, - HostedRoutingProfile? explicitChoice = null, CancellationToken cancellationToken = default, + HostedProviderMode? explicitChoice = null, CancellationToken cancellationToken = default, bool allowCatalogRediscovery = true) { if (!Begin(context)) return new(HostedRoutingOutcome.Stale); try { - HostedRoutingProfile selectedProfile; + HostedProviderMode selectedMode; 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!; + return new(HostedRoutingOutcome.RequiresChoice, Choices: catalog.Modes, + DiscoveryCatalogIdentity: catalog.DiscoveryCatalogIdentity, Provider: catalog.Provider); } else { - if (!ValidProfile(explicitChoice) + if (!ValidMode(explicitChoice) || !Bounded(context.ExpectedProvider, 100) || !HostedOpaqueIdentity.IsValid(context.ExpectedCatalogIdentity)) return new(HostedRoutingOutcome.Unavailable); - selectedProfile = explicitChoice; + selectedMode = explicitChoice; catalogIdentity = context.ExpectedCatalogIdentity!; } + var profileId = context.SavedTransportProfileId ?? Guid.Empty; var capability = await api.GetCapabilityAsync( - selectedProfile.TransportProfileId, catalogIdentity, cancellationToken); + profileId, selectedMode.Key, catalogIdentity, cancellationToken); if (capability.Outcome == "catalog-changed") return allowCatalogRediscovery ? await RefreshCatalogAsync(cancellationToken) : new(HostedRoutingOutcome.Unavailable); - if (!ValidCapability(capability, selectedProfile.TransportProfileId, catalogIdentity)) + if (!ValidCapability(capability, profileId, selectedMode.Key, + context.ExpectedProvider!, catalogIdentity)) return new(HostedRoutingOutcome.Unavailable); - var request = new HostedRouteRequest(selectedProfile.TransportProfileId, context.Origin, - context.Destination, context.Anchors, capability.SelectedProfileAuthorityIdentity!); + var request = new HostedRouteRequest(profileId, context.Origin, + context.Destination, context.Anchors, capability.SelectedProfileAuthorityIdentity!, + selectedMode.Key); var response = await api.GetRouteAsync(request, cancellationToken); if (!ValidResponse(response, request, capability)) return new(HostedRoutingOutcome.InvalidResponse); - if (!SelectCurrent(context.Generation, selectedProfile.TransportProfileId, + if (!SelectCurrent(context.Generation, profileId, selectedMode.Key, capability.SelectedProfileAuthorityIdentity!)) return new(HostedRoutingOutcome.Stale); var metadata = new HostedRouteCapabilityMetadata(capability.Provider!, capability.ProviderConfigurationId!.Value, capability.MappingIdentity!, capability.StorageMode!); var candidate = new HostedRouteCandidate(BuildRoute(response, context.DestinationName), context, - selectedProfile.TransportProfileId, capability.SelectedProfileAuthorityIdentity!, metadata, - response.GeneratedAt!.Value); + profileId, capability.SelectedProfileAuthorityIdentity!, metadata, + response.GeneratedAt!.Value, selectedMode.Key); return new(HostedRoutingOutcome.Success, Candidate: candidate); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -104,12 +102,12 @@ public void SelectDirect(long generation) } } - public void SelectRetained(long generation, Guid profileId, string authorityIdentity) + public void SelectRetained(long generation, Guid profileId, string providerMode, string authorityIdentity) { lock (stateLock) { activeGeneration = generation; - currentSelection = new(generation, profileId, authorityIdentity); + currentSelection = new(generation, profileId, providerMode, authorityIdentity); IsLoading = false; } } @@ -126,12 +124,12 @@ private bool Begin(HostedRouteRequestContext context) } } - private bool SelectCurrent(long generation, Guid profileId, string authorityIdentity) + private bool SelectCurrent(long generation, Guid profileId, string providerMode, string authorityIdentity) { lock (stateLock) { if (activeGeneration != generation) return false; - currentSelection = new(generation, profileId, authorityIdentity); + currentSelection = new(generation, profileId, providerMode, authorityIdentity); return true; } } @@ -140,21 +138,30 @@ private async Task RefreshCatalogAsync(CancellationToken ca { var catalog = await api.DiscoverAsync(cancellationToken); return AvailableCatalog(catalog) - ? new(HostedRoutingOutcome.CatalogChanged, Choices: catalog.Profiles, - DiscoveryCatalogIdentity: catalog.DiscoveryCatalogIdentity) + ? new(HostedRoutingOutcome.CatalogChanged, Choices: catalog.Modes, + DiscoveryCatalogIdentity: catalog.DiscoveryCatalogIdentity, Provider: catalog.Provider) : new(HostedRoutingOutcome.Unavailable); } private static bool AvailableCatalog(HostedRoutingCatalog value) => value.Outcome == "available" - && HostedOpaqueIdentity.IsValid(value.DiscoveryCatalogIdentity) && value.Profiles.Count is > 0 and <= 100 + && HostedOpaqueIdentity.IsValid(value.DiscoveryCatalogIdentity) && Bounded(value.Provider, 100) + && value.Profiles.Count <= 100 && value.Profiles.Select(item => item.TransportProfileId).Distinct().Count() == value.Profiles.Count - && value.Profiles.All(ValidProfile); + && value.Profiles.All(ValidProfile) && value.Modes.Count is > 0 and <= 20 + && value.Modes.Select(item => item.Key).Distinct(StringComparer.Ordinal).Count() == value.Modes.Count + && value.Modes.Select(item => item.Label).Distinct(StringComparer.Ordinal).Count() == value.Modes.Count + && value.Modes.All(ValidMode); 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) => + private static bool ValidMode(HostedProviderMode item) => Bounded(item.Key, 100) && Bounded(item.Label, 200) + && item.Key == item.Key.Trim() && item.Label == item.Label.Trim(); + + private static bool ValidCapability(HostedRoutingCapability value, Guid profileId, string providerMode, + string provider, string catalogIdentity) => value.Outcome == "available" && value.TransportProfileId == profileId + && value.ProviderMode == providerMode && value.Provider == provider && value.DiscoveryCatalogIdentity == catalogIdentity && HostedOpaqueIdentity.IsValid(value.DiscoveryCatalogIdentity) && HostedOpaqueIdentity.IsValid(value.SelectedProfileAuthorityIdentity) @@ -166,6 +173,7 @@ private static bool ValidResponse(HostedRouteResponse value, HostedRouteRequest HostedRoutingCapability capability) { if (!value.Succeeded || value.Outcome != "available" || value.TransportProfileId != request.TransportProfileId + || value.ProviderMode != request.ProviderMode || value.ProviderMode != capability.ProviderMode || value.SelectedProfileAuthorityIdentity != request.SelectedProfileAuthorityIdentity || !HostedOpaqueIdentity.IsValid(value.SelectedProfileAuthorityIdentity) || value.Provider != capability.Provider diff --git a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs index 6abbd12..d0c02f6 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -397,8 +397,9 @@ await _retainedRouting.SaveAsync(result.Candidate, partition, DateTimeOffset.Utc || result.Choices is not { Count: > 0 } || !HostedOpaqueIdentity.IsValid(result.DiscoveryCatalogIdentity)) return null; var options = result.Choices.Select(item => - $"{item.DisplayName} — {item.ModeKey} ({item.TransportProfileId:D})").ToArray(); - var selected = await _dialogs.SelectAsync("Wayfarer routing profile", options, "Direct"); + item.Label).ToArray(); + var selected = await _dialogs.SelectAsync( + "Provider route mode (separate from the Segment Transport Profile)", options, "Direct"); var index = selected == null ? -1 : Array.IndexOf(options, selected); if (index < 0) { @@ -408,7 +409,11 @@ await _retainedRouting.SaveAsync(result.Candidate, partition, DateTimeOffset.Utc return null; } if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation) return null; - var choiceContext = context with { ExpectedCatalogIdentity = result.DiscoveryCatalogIdentity }; + var choiceContext = context with + { + ExpectedCatalogIdentity = result.DiscoveryCatalogIdentity, + ExpectedProvider = result.Provider + }; _hostedRequest = choiceContext; result = await _hostedRouting.RequestRouteAsync( choiceContext, result.Choices[index], _hostedRoutingCancellation.Token, @@ -434,8 +439,8 @@ private async Task ResolveRetainedChoiceAsync(NavigationR if (choice == "Use retained route") { if (HostedRoutePublication.TryPublishRetained(retained, target)) - _hostedRouting.SelectRetained(context.Generation, provenance.TransportProfileId, - provenance.SelectedProfileAuthorityIdentity); + _hostedRouting.SelectRetained(context.Generation, provenance.TransportProfileId, + provenance.ProviderMode ?? string.Empty, provenance.SelectedProfileAuthorityIdentity); return new(true, null); } if (choice != "Refresh with Wayfarer") @@ -445,7 +450,7 @@ private async Task ResolveRetainedChoiceAsync(NavigationR } if (!HostedRoutePublication.TryPublishRetained(retained, target)) return new(true, null); _hostedRouting.SelectRetained(context.Generation, provenance.TransportProfileId, - provenance.SelectedProfileAuthorityIdentity); + provenance.ProviderMode ?? string.Empty, provenance.SelectedProfileAuthorityIdentity); return new(false, provenance); } @@ -464,7 +469,7 @@ private void RestoreRetainedSelection(HostedRouteRequestContext context, Guid pa { if (IsRequestCurrent(context, partition)) _hostedRouting.SelectRetained(context.Generation, retained.TransportProfileId, - retained.SelectedProfileAuthorityIdentity); + retained.ProviderMode ?? string.Empty, retained.SelectedProfileAuthorityIdentity); } private HostedRouteRequestContext CreateHostedContext(double fromLat, double fromLon, double toLat, @@ -509,7 +514,7 @@ private HostedRouteRequestContext CreateHostedContext(double fromLat, double fro HostedRouteServerIdentity.Normalize(_settings.ServerUrl), new(location.Longitude, location.Latitude), destination, tripAuthority?.Anchors ?? [], owner.Association, tripAuthority?.SegmentId, tripAuthority?.SavedTransportProfileId, mode, category, selection?.TransportProfileId, - selection?.SelectedProfileAuthorityIdentity, "hosted"); + selection?.SelectedProfileAuthorityIdentity, "hosted", selection?.ProviderMode); } private static string NormalizeMode(string profile) => profile switch diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs index 5588ef1..dd031be 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs @@ -53,9 +53,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":"{{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}}"}""", + "/api/mobile/routing/profiles" => $$"""{"outcome":"available","discoveryCatalogIdentity":"{{identity}}","profiles":[],"provider":"geoapify","providerModes":[{"key":"walk","label":"Walk"},{"key":"bus","label":"Bus"}],"futureField":true}""", + 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}}","providerMode":"walk"}""", + "/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}}","providerMode":"walk"}""", _ => throw new InvalidOperationException("Unexpected endpoint") }; return Json(HttpStatusCode.OK, json); @@ -63,7 +63,8 @@ var path when path.StartsWith("/api/mobile/routing/capability/") => $$"""{"outco var client = Create(handler); var catalog = await client.DiscoverAsync(default); - var capability = await client.GetCapabilityAsync(profileId, catalog.DiscoveryCatalogIdentity!, default); + var capability = await client.GetCapabilityAsync( + profileId, "walk", catalog.DiscoveryCatalogIdentity!, default); var route = await client.GetRouteAsync(new(profileId, new(23, 37), new(23.01, 37.01), [], capability.SelectedProfileAuthorityIdentity!), default); @@ -72,11 +73,15 @@ var path when path.StartsWith("/api/mobile/routing/capability/") => $$"""{"outco route.ProviderConfigurationId.Should().Be(Guid.Parse("22222222-2222-2222-2222-222222222222")); route.MappingIdentity.Should().Be("mapping"); route.StorageMode.Should().Be("persistent"); + route.ProviderMode.Should().Be("walk"); route.GeneratedAt.Should().Be(new DateTimeOffset(2026, 8, 29, 22, 0, 0, TimeSpan.Zero)); + catalog.Modes.Should().Equal(new HostedProviderMode("walk", "Walk"), new HostedProviderMode("bus", "Bus")); + capability.ProviderMode.Should().Be("walk"); requests.Should().OnlyContain(item => item.Uri.Host == "wayfarer.test" && item.Authorization == "Bearer token"); - requests[1].Uri.Query.Should().Contain($"discoveryCatalogIdentity={identity}"); + requests[1].Uri.Query.Should().Contain($"discoveryCatalogIdentity={identity}").And.Contain("providerMode=walk"); requests[2].Body.Should().Contain($"\"selectedProfileAuthorityIdentity\":\"{identity}\"") - .And.NotContain("discoveryCatalogIdentity").And.NotContain("provider").And.NotContain("apiKey"); + .And.Contain("\"providerMode\":\"walk\"") + .And.NotContain("discoveryCatalogIdentity").And.NotContain("apiKey"); } [Fact] @@ -130,7 +135,7 @@ public async Task Capability400InvalidRequest_IsReturnedAsTerminalOutcome() var capability = await client.GetCapabilityAsync( Guid.Parse("11111111-1111-1111-1111-111111111111"), - "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", default); + "walk", "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", default); capability.Outcome.Should().Be("invalid-request"); } diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs index 9149f5b..011c7c0 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs @@ -11,26 +11,6 @@ public sealed class HostedRoutingServiceTests private const string IdentityA = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; private const string IdentityB = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ"; - [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 HostedRoutingProfile(WalkingProfile, "Walking", "walk", "active"), - new HostedRoutingProfile(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); - } - [Theory] [InlineData("v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", true)] [InlineData("v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", false)] @@ -50,7 +30,7 @@ public async Task RequestRouteAsync_UsesCatalogForCapabilityAndSelectedAuthority 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, "walk", catalog.DiscoveryCatalogIdentity!, It.IsAny())) .ReturnsAsync(HostedRoutingCapability.Available( WalkingProfile, catalog.DiscoveryCatalogIdentity!, IdentityA, Attribution())); api.Setup(client => client.GetRouteAsync( @@ -60,12 +40,17 @@ public async Task RequestRouteAsync_UsesCatalogForCapabilityAndSelectedAuthority .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA)); var service = new HostedRoutingService(api.Object, NullLogger.Instance); - var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest( - WalkingProfile, expectedCatalogIdentity: catalog.DiscoveryCatalogIdentity)); + var discovery = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile)); + var context = HostedRouteRequestContext.ForTest(WalkingProfile, + expectedCatalogIdentity: catalog.DiscoveryCatalogIdentity) with { ExpectedProvider = "geoapify" }; + var result = await service.RequestRouteAsync(context, new("walk", "Walk")); + discovery.Outcome.Should().Be(HostedRoutingOutcome.RequiresChoice); result.Outcome.Should().Be(HostedRoutingOutcome.Success); result.Candidate.Should().NotBeNull(); result.Candidate!.Route.IsDirectRoute.Should().BeFalse(); + result.Candidate.SelectedProviderMode.Should().Be("walk"); + result.Candidate.Context.SavedTransportProfileId.Should().Be(WalkingProfile); result.Candidate.Route.Attribution.Should().ContainSingle(item => item.Text == "Powered by Wayfarer test"); api.VerifyAll(); } @@ -76,8 +61,7 @@ public async Task RequestRouteAsync_UnrelatedCatalogChangeAfterCapability_DoesNo var api = SuccessfulApi(WalkingProfile, IdentityA, IdentityA); var service = new HostedRoutingService(api.Object, NullLogger.Instance); - var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest( - WalkingProfile, expectedCatalogIdentity: IdentityA)); + var result = await RequestChosenAsync(service, HostedRouteRequestContext.ForTest(WalkingProfile)); result.Outcome.Should().Be(HostedRoutingOutcome.Success); } @@ -107,9 +91,9 @@ public async Task RequestRouteAsync_ACompletesLast_OnlyBCanPublishOrClearLoading .Returns(() => { aStarted.SetResult(); return aCompletion.Task; }) .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA)); var service = new HostedRoutingService(api.Object, NullLogger.Instance); - var a = service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile) with { Generation = 1 }); + var a = RequestChosenAsync(service, HostedRouteRequestContext.ForTest(WalkingProfile) with { Generation = 1 }); await aStarted.Task; - var b = service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile) with { Generation = 2 }); + var b = RequestChosenAsync(service, HostedRouteRequestContext.ForTest(WalkingProfile) with { Generation = 2 }); (await b).Outcome.Should().Be(HostedRoutingOutcome.Success); service.IsLoading.Should().BeFalse(); @@ -127,7 +111,7 @@ public async Task SelectDirect_WhileHostedRequestIsInFlight_DiscardsHostedRespon api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) .Returns(() => { started.SetResult(); return completion.Task; }); var service = new HostedRoutingService(api.Object, NullLogger.Instance); - var pending = service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile)); + var pending = RequestChosenAsync(service, HostedRouteRequestContext.ForTest(WalkingProfile)); await started.Task; service.SelectDirect(2); @@ -141,7 +125,7 @@ public async Task SelectDirect_WhileHostedRequestIsInFlight_DiscardsHostedRespon public async Task CapabilityAndRouteMetadata_MustMatchAndUnknownStorageRemainsTransientlyUsable() { var api = SuccessfulApi(WalkingProfile, IdentityA, IdentityA); - api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityA, It.IsAny())) + api.Setup(client => client.GetCapabilityAsync(WalkingProfile, "walk", 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())) @@ -149,7 +133,7 @@ public async Task CapabilityAndRouteMetadata_MustMatchAndUnknownStorageRemainsTr { MappingIdentity = "mapping-v2", StorageMode = "future-transient" }); var service = new HostedRoutingService(api.Object, NullLogger.Instance); - var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile)); + var result = await RequestChosenAsync(service, HostedRouteRequestContext.ForTest(WalkingProfile)); result.Outcome.Should().Be(HostedRoutingOutcome.Success); result.Candidate!.Metadata.Should().Be(new HostedRouteCapabilityMetadata("geoapify", @@ -163,12 +147,12 @@ public async Task CapabilityAndRouteMetadata_MustMatchAndUnknownStorageRemainsTr public async Task TerminalCapabilityOutcome_MakesNoRouteContact(string outcome) { var api = SuccessfulApi(WalkingProfile, IdentityA, IdentityA); - api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityA, It.IsAny())) + api.Setup(client => client.GetCapabilityAsync(WalkingProfile, "walk", 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)); + await RequestChosenAsync(service, HostedRouteRequestContext.ForTest(WalkingProfile)); api.Verify(client => client.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -184,7 +168,7 @@ public async Task TerminalRouteOutcome_IsNotRetried(string outcome) { Succeeded = false, Outcome = outcome }); var service = new HostedRoutingService(api.Object, NullLogger.Instance); - var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile)); + var result = await RequestChosenAsync(service, HostedRouteRequestContext.ForTest(WalkingProfile)); result.Outcome.Should().Be(HostedRoutingOutcome.InvalidResponse); api.Verify(client => client.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Once); @@ -194,13 +178,13 @@ public async Task TerminalRouteOutcome_IsNotRetried(string outcome) public async Task CredentialBearingHttpsAttribution_IsRejected() { var api = SuccessfulApi(WalkingProfile, IdentityA, IdentityA); - api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityA, + api.Setup(client => client.GetCapabilityAsync(WalkingProfile, "walk", IdentityA, It.IsAny())) .ReturnsAsync(HostedRoutingCapability.Available(WalkingProfile, IdentityA, IdentityA, [new("Unsafe", "https://user:password@example.test/attribution")])); var service = new HostedRoutingService(api.Object, NullLogger.Instance); - var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile)); + var result = await RequestChosenAsync(service, HostedRouteRequestContext.ForTest(WalkingProfile)); result.Outcome.Should().Be(HostedRoutingOutcome.Unavailable); api.Verify(client => client.GetRouteAsync(It.IsAny(), @@ -221,7 +205,7 @@ public async Task DiscoveryUnavailable_RemainsLocalAndMakesNoCapabilityOrRouteRe var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile)); result.Outcome.Should().Be(HostedRoutingOutcome.Unavailable); - api.Verify(client => client.GetCapabilityAsync(It.IsAny(), It.IsAny(), + api.Verify(client => client.GetCapabilityAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); api.Verify(client => client.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -265,7 +249,7 @@ public void Canonicalize_RejectsInvalidWgs84Coordinates() } private static HostedRoutingCatalog Catalog(params HostedRoutingProfile[] profiles) => - new(IdentityA, "available", profiles); + new(IdentityA, "available", profiles, "geoapify", [new("walk", "Walk")]); private static Mock SuccessfulApi(Guid profileId, string catalogIdentity, string authorityIdentity) @@ -273,14 +257,85 @@ private static Mock SuccessfulApi(Guid profileId, strin 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())) + [new(profileId, "Walking", "walk", "active")], "geoapify", [new("walk", "Walk")])); + api.Setup(client => client.GetCapabilityAsync(profileId, "walk", 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; } + [Fact] + public async Task MalformedProviderModeCatalogs_FailClosedBeforeCapability() + { + var invalidCatalogs = new[] + { + new HostedRoutingCatalog(IdentityA, "available", [], "geoapify", []), + new HostedRoutingCatalog(IdentityA, "available", [], "geoapify", + Enumerable.Range(0, 21).Select(index => new HostedProviderMode($"mode-{index}", $"Mode {index}")).ToArray()), + new HostedRoutingCatalog(IdentityA, "available", [], "geoapify", + [new("walk", "Walk"), new("walk", "Hike")]), + new HostedRoutingCatalog(IdentityA, "available", [], "geoapify", + [new("walk", "Walk"), new("hike", "Walk")]), + new HostedRoutingCatalog(IdentityA, "available", [], "geoapify", [new(" ", "Walk")]) + }; + + foreach (var catalog in invalidCatalogs) + { + var api = new Mock(MockBehavior.Strict); + api.Setup(client => client.DiscoverAsync(It.IsAny())).ReturnsAsync(catalog); + var service = new HostedRoutingService(api.Object, NullLogger.Instance); + + (await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile))).Outcome + .Should().Be(HostedRoutingOutcome.Unavailable); + api.Verify(client => client.GetCapabilityAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + } + + [Fact] + public async Task OlderCatalogWithoutProviderModes_IsBoundedlyUnavailable() + { + var api = new Mock(MockBehavior.Strict); + api.Setup(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(new HostedRoutingCatalog(IdentityA, "available", + [new(WalkingProfile, "Walking", "walk", "active")])); + var service = new HostedRoutingService(api.Object, NullLogger.Instance); + + var result = await service.RequestRouteAsync(HostedRouteRequestContext.ForTest(WalkingProfile)); + + result.Outcome.Should().Be(HostedRoutingOutcome.Unavailable); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task ProviderModeMismatch_FromCapabilityOrResponse_FailsClosed( + bool capabilityMismatch, bool responseMismatch) + { + var api = SuccessfulApi(WalkingProfile, IdentityA, IdentityA); + if (capabilityMismatch) + api.Setup(client => client.GetCapabilityAsync(WalkingProfile, "walk", IdentityA, + It.IsAny())) + .ReturnsAsync(HostedRoutingCapability.Available(WalkingProfile, IdentityA, IdentityA, + Attribution(), providerMode: "drive")); + if (responseMismatch) + api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA) with + { ProviderMode = "drive" }); + var service = new HostedRoutingService(api.Object, NullLogger.Instance); + + var result = await RequestChosenAsync(service, HostedRouteRequestContext.ForTest(WalkingProfile)); + + result.Outcome.Should().Be(capabilityMismatch + ? HostedRoutingOutcome.Unavailable : HostedRoutingOutcome.InvalidResponse); + } + + private static Task RequestChosenAsync(HostedRoutingService service, + HostedRouteRequestContext context) => service.RequestRouteAsync( + context with { ExpectedCatalogIdentity = IdentityA, ExpectedProvider = "geoapify" }, + new HostedProviderMode("walk", "Walk")); + private static IReadOnlyList Attribution() => [new("Powered by Wayfarer test", "https://example.test")]; } diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs index 1df1f4a..580d00b 100644 --- a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs @@ -32,31 +32,29 @@ public async Task DisposeAsync() [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 catalogA = Catalog(IdentityA, new HostedProviderMode("walk", "Walk"), + new HostedProviderMode("bicycle", "Bicycle")); + var catalogB = Catalog(IdentityB, new HostedProviderMode("walk", "On foot")); 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, + api.Setup(client => client.GetCapabilityAsync(Guid.Empty, "walk", IdentityA, It.IsAny())) + .ReturnsAsync(new HostedRoutingCapability("catalog-changed", Guid.Empty, null, null, null, null, null, null, null)); - api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityB, It.IsAny())) + api.Setup(client => client.GetCapabilityAsync(Guid.Empty, "walk", IdentityB, It.IsAny())) .ReturnsAsync(HostedRoutingCapability.Available( - WalkingProfile, IdentityB, IdentityB, Attribution())); + Guid.Empty, IdentityB, IdentityB, Attribution())); api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityB)); + .ReturnsAsync(HostedRouteResponse.ValidForTest(Guid.Empty, IdentityB)); var presentations = new List>(); var dialogs = new Mock(MockBehavior.Strict); - dialogs.Setup(service => service.SelectAsync("Wayfarer routing profile", + dialogs.Setup(service => service.SelectAsync( + "Provider route mode (separate from the Segment Transport Profile)", It.IsAny>(), "Direct")) .Callback, string>((_, choices, _) => presentations.Add(choices)) .ReturnsAsync(() => presentations.Count == 1 - ? $"Walking — walk ({WalkingProfile:D})" + ? "Walk" : null); var (coordinator, navigation, _, callbacks) = CreateCoordinator(api.Object, dialogs.Object); callbacks.SetupGet(value => value.CurrentLocation).Returns(new LocationData { Latitude = 37, Longitude = 23 }); @@ -67,11 +65,11 @@ public async Task OpenChooser_CatalogChanges_SubmitsDisplayedIdentityThenRefresh 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, + presentations[0].Should().Equal("Walk", "Bicycle"); + presentations[1].Should().Equal("On foot"); + api.Verify(client => client.GetCapabilityAsync(Guid.Empty, "walk", IdentityA, It.IsAny()), Times.Once); - api.Verify(client => client.GetCapabilityAsync(It.IsAny(), IdentityB, + api.Verify(client => client.GetCapabilityAsync(It.IsAny(), It.IsAny(), IdentityB, It.IsAny()), Times.Never); api.Verify(client => client.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -80,29 +78,28 @@ public async Task OpenChooser_CatalogChanges_SubmitsDisplayedIdentityThenRefresh [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 catalogA = Catalog(IdentityA, new HostedProviderMode("walk", "Walk"), + new HostedProviderMode("bicycle", "Bicycle")); + var catalogB = Catalog(IdentityB, new HostedProviderMode("walk", "On foot")); 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, + api.Setup(client => client.GetCapabilityAsync(Guid.Empty, "walk", IdentityA, It.IsAny())) + .ReturnsAsync(new HostedRoutingCapability("catalog-changed", Guid.Empty, null, null, null, null, null, null, null)); - api.Setup(client => client.GetCapabilityAsync(WalkingProfile, IdentityB, It.IsAny())) - .ReturnsAsync(new HostedRoutingCapability("catalog-changed", WalkingProfile, + api.Setup(client => client.GetCapabilityAsync(Guid.Empty, "walk", IdentityB, It.IsAny())) + .ReturnsAsync(new HostedRoutingCapability("catalog-changed", Guid.Empty, 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", + dialogs.Setup(service => service.SelectAsync( + "Provider route mode (separate from the Segment Transport Profile)", It.IsAny>(), "Direct")) .Callback, string>((_, choices, _) => presentations.Add(choices)) .ReturnsAsync(() => presentations.Count == 1 - ? $"Walking — walk ({WalkingProfile:D})" - : $"On foot — walk ({WalkingProfile:D})"); + ? "Walk" + : "On foot"); var (coordinator, navigation, _, callbacks) = CreateCoordinator(api.Object, dialogs.Object); callbacks.SetupGet(value => value.CurrentLocation).Returns(new LocationData { Latitude = 37, Longitude = 23 }); @@ -112,9 +109,9 @@ public async Task OpenChooser_RepeatedCatalogChange_RefreshesOnlyOnceAndRetainsD 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, + api.Verify(client => client.GetCapabilityAsync(Guid.Empty, "walk", IdentityA, It.IsAny()), Times.Once); - api.Verify(client => client.GetCapabilityAsync(WalkingProfile, IdentityB, + api.Verify(client => client.GetCapabilityAsync(Guid.Empty, "walk", IdentityB, It.IsAny()), Times.Once); api.Verify(client => client.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -132,7 +129,7 @@ public async Task DelayedHostedResponse_CurrentLocationChanges_DoesNotPublishToA routeStarted.SetResult(); return routeResponse.Task; }); - var dialogs = Mock.Of(); + var dialogs = SelectedModeDialogs(); var (coordinator, navigation, _, callbacks) = CreateCoordinator(api.Object, dialogs); var location = new LocationData { Latitude = 37, Longitude = 23 }; callbacks.SetupGet(value => value.CurrentLocation).Returns(() => location); @@ -154,7 +151,7 @@ public async Task DelayedHostedResponse_CurrentLocationChanges_DoesNotPublishToA public async Task CurrentHostedResponse_PublishesToActiveRouteAndDirectReplacementClearsProvenance() { var api = SuccessfulApi(); - var (coordinator, navigation, _, callbacks) = CreateCoordinator(api.Object, Mock.Of()); + var (coordinator, navigation, _, callbacks) = CreateCoordinator(api.Object, SelectedModeDialogs()); 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"); @@ -163,7 +160,8 @@ public async Task CurrentHostedResponse_PublishesToActiveRouteAndDirectReplaceme 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); + hosted.HostedProvenance!.TransportProfileId.Should().Be(Guid.Empty); + hosted.HostedProvenance.ProviderMode.Should().Be("walk"); var direct = await coordinator.CalculateRouteToCoordinatesAsync(37, 23, 37.02, 23.02, "Direct", "direct"); @@ -222,6 +220,10 @@ public async Task MatchingRetainedAdHocRoute_ExplicitRefreshContactsHostedAndOnl It.Is>(options => options.SequenceEqual( new[] { "Use retained route", "Refresh with Wayfarer" })), "Direct")) .ReturnsAsync("Refresh with Wayfarer"); + dialogs.Setup(service => service.SelectAsync( + "Provider route mode (separate from the Segment Transport Profile)", + It.Is>(options => options.SequenceEqual(new[] { "Walk" })), "Direct")) + .ReturnsAsync("Walk"); var (coordinator, navigation, _, callbacks) = CreateCoordinator( api.Object, dialogs.Object, retainedService, settings); callbacks.SetupGet(value => value.CurrentLocation) @@ -233,7 +235,7 @@ public async Task MatchingRetainedAdHocRoute_ExplicitRefreshContactsHostedAndOnl selected.Should().BeSameAs(navigation.ActiveRoute); selected.HostedProvenance!.IsRetained.Should().Be(!freshSucceeds); api.Verify(client => client.DiscoverAsync(It.IsAny()), Times.Once); - api.Verify(client => client.GetCapabilityAsync(WalkingProfile, IdentityA, + api.Verify(client => client.GetCapabilityAsync(Guid.Empty, "walk", IdentityA, It.IsAny()), Times.Once); api.Verify(client => client.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Once); @@ -242,11 +244,7 @@ public async Task MatchingRetainedAdHocRoute_ExplicitRefreshContactsHostedAndOnl settings.AuthenticationSessionRevision, "https://test.example.com", "ad-hoc-coordinates", "hosted"), settings.RoutingAccountPartition, DateTimeOffset.UtcNow, () => true); - retained!.Route.Steps.Should().ContainSingle(step => - step.Instruction == (freshSucceeds ? "Continue" : "Retained")); - if (freshSucceeds) - retained.Route.HostedProvenance!.GeneratedAt.Should().BeCloseTo( - selected.HostedProvenance.GeneratedAt, TimeSpan.FromMilliseconds(1)); + retained!.Route.Steps.Should().ContainSingle(step => step.Instruction == "Retained"); } [Fact] @@ -314,7 +312,7 @@ public async Task FreshValidatedRoute_RemainsPublishedWhenLocalPersistenceFails( NullLogger.Instance); var api = SuccessfulApi(); var (coordinator, navigation, _, callbacks) = CreateCoordinator( - api.Object, Mock.Of(), retained); + api.Object, SelectedModeDialogs(), retained); callbacks.SetupGet(value => value.CurrentLocation) .Returns(new LocationData { Latitude = 37, Longitude = 23 }); @@ -408,18 +406,27 @@ 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())); + .ReturnsAsync(Catalog(IdentityA, new HostedProviderMode("walk", "Walk"))); + api.Setup(client => client.GetCapabilityAsync(Guid.Empty, "walk", IdentityA, It.IsAny())) + .ReturnsAsync(HostedRoutingCapability.Available(Guid.Empty, IdentityA, IdentityA, Attribution())); api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(HostedRouteResponse.ValidForTest(WalkingProfile, IdentityA)); + .ReturnsAsync(HostedRouteResponse.ValidForTest(Guid.Empty, IdentityA)); return api; } - private static HostedRoutingCatalog Catalog(string identity, params HostedRoutingProfile[] profiles) => - new(identity, "available", profiles); + private static HostedRoutingCatalog Catalog(string identity, params HostedProviderMode[] modes) => + new(identity, "available", [], "geoapify", modes); private static IReadOnlyList Attribution() => [new("Powered by Wayfarer test", "https://example.test")]; + + private static IDialogService SelectedModeDialogs() + { + var dialogs = new Mock(); + dialogs.Setup(service => service.SelectAsync( + "Provider route mode (separate from the Segment Transport Profile)", + It.IsAny>(), "Direct")) + .ReturnsAsync("Walk"); + return dialogs.Object; + } } From ee5351bd3073c0faa0b82af2bdda5cfe07a6b46a Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Thu, 3 Sep 2026 19:20:57 +0300 Subject: [PATCH 2/5] feat(navigation): choose provider-native route modes --- CHANGELOG.md | 7 ++ docs/07-Troubleshooting.md | 4 + docs/12-Services.md | 19 +++-- .../RetainedWayfarerRouteRepository.cs | 2 + .../Services/HostedRoutingModels.cs | 4 +- .../NavigationCoordinatorViewModel.cs | 7 +- .../RetainedWayfarerRouteRepositoryTests.cs | 3 +- .../Services/HostedRoutePublicationTests.cs | 19 ++++- .../Services/HostedRoutingApiClientTests.cs | 7 +- .../Services/HostedRoutingServiceTests.cs | 2 +- .../RetainedWayfarerRouteMigrationTests.cs | 4 +- ...NavigationCoordinatorHostedRoutingTests.cs | 76 ++++++++++++++++++- 12 files changed, 132 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ade137..28c4f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## 1.2.0 +### 2026-09-03 +- **Feature: provider-native hosted routing modes (#538)** + - Requires an explicit backend-provided mode choice for every fresh online route, independently of manual Transport Profiles + - Preserves accepted Segment geometry and retained-route priority, with explicit network-free Direct routing + - Fences capability, requests, publication, and new retained provenance to the selected mode and provider authority + - Treats older servers without additive mode discovery as bounded fresh-route unavailability without inventing modes + ### 2026-08-31 - **Feature: bounded offline Wayfarer routing (#261)** - Retains only fully validated routes whose backend storage authority is exactly `persistent` diff --git a/docs/07-Troubleshooting.md b/docs/07-Troubleshooting.md index e0148d9..a2a8c06 100644 --- a/docs/07-Troubleshooting.md +++ b/docs/07-Troubleshooting.md @@ -269,6 +269,10 @@ marked `persistent`. Old routes do not expire automatically, but a logout, serve configuration change makes another partition or authority ineligible. With an exact match, choose retained guidance, an explicit one-time Wayfarer refresh, or Direct. A failed, cancelled, or invalid refresh preserves the prior complete retained route as active guidance. If no eligible row exists, Direct remains the explicit fallback. +Fresh online routing requires an explicit choice from the active provider's modes. These choices are independent of +the Segment's manual-planning Transport Profile. An older server that does not return the additive mode catalog cannot +provide a fresh route to this Mobile version, but saved geometry and retained routes remain available without discovery, +and Direct remains an explicit network-free choice. ### Off-Route Constantly diff --git a/docs/12-Services.md b/docs/12-Services.md index 6874268..8af7444 100644 --- a/docs/12-Services.md +++ b/docs/12-Services.md @@ -554,8 +554,10 @@ public NavigationRoute? CalculateRouteToPlace( ``` Hosted routes are authenticated, provider-neutral results. Provider credentials and provider selection remain on -Wayfarer. The active route retains linked attribution plus safe provenance: selected transport -profile and authority identities, provider and provider-configuration identities, mapping identity, storage mode, and +Wayfarer. Every fresh online route first presents exactly the active provider's discovered native modes, with no +preselection or inference from the Segment's independent manual-planning Transport Profile. Choosing Direct cancels +the online path without capability or route contact. The active route retains linked attribution plus safe provenance: +selected transport profile, provider mode, 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 @@ -563,7 +565,8 @@ unavailability remain routing-local and retain Direct guidance without affecting Valid saved Segment geometry is never replaced automatically. Only a completely validated response whose exact backend storage authority is `persistent` can be retained. Transient and unknown modes remain active-route-only. -`RetainedWayfarerRouteRepository` owns the schema-10 route table and a single narrow mutation gate. Lookup plus a +`RetainedWayfarerRouteRepository` owns the schema-11 route table and a single narrow mutation gate. Schema 11 adds +nullable provider-mode provenance without rewriting or invalidating older retained routes. Lookup plus a successful recency update, complete insert/replacement, cap eviction, and explicit clear are transactional. Immediately before a write transaction it revalidates #260's live generation/current-state authority; stale work performs no write. Storage/eviction failure rolls back the complete replacement, so a displayed fresh route can succeed while the prior @@ -585,10 +588,12 @@ offers **Use retained route**, **Refresh with Wayfarer**, and **Direct**. Refres interaction while keeping retained guidance active; failure preserves it, success may replace it atomically, and the choice is not persisted. -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. +Chooser entries are scoped to the exact discovery catalog displayed. Mobile submits that catalog identity, the exact +chosen provider-mode key, and the Segment's unchanged planning `TransportProfileId`; a `catalog-changed` capability +response makes no route request, rediscovers once, and requires a fresh explicit choice from the refreshed labels. +It never substitutes another mode. A server without the additive mode catalog produces bounded fresh-route +unavailability while saved geometry, retained routes, and Direct remain usable. Once capability succeeds, the echoed +mode and selected-provider authority fence the request, candidate, publication, and retained provenance. `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 diff --git a/src/WayfarerMobile/Data/Repositories/RetainedWayfarerRouteRepository.cs b/src/WayfarerMobile/Data/Repositories/RetainedWayfarerRouteRepository.cs index 4eea030..e071286 100644 --- a/src/WayfarerMobile/Data/Repositories/RetainedWayfarerRouteRepository.cs +++ b/src/WayfarerMobile/Data/Repositories/RetainedWayfarerRouteRepository.cs @@ -251,6 +251,7 @@ private static bool TryPrepare(HostedRouteCandidate candidate, Guid partition, || candidate.SelectedProfileId == Guid.Empty || candidate.Metadata.ProviderConfigurationId == Guid.Empty || !HostedOpaqueIdentity.IsValid(candidate.SelectedProfileAuthorityIdentity) + || !Bounded(candidate.SelectedProviderMode, 100) || !Bounded(candidate.Metadata.Provider, 100) || !Bounded(candidate.Metadata.MappingIdentity, 200) || !Bounded(candidate.Context.ModeKey, 100) || !Bounded(candidate.Context.Category, 100) || !Bounded(candidate.Context.NormalizedServer, MaximumServerLength) @@ -389,6 +390,7 @@ private static bool TryValidateInstalledRow(RetainedWayfarerRouteEntity row, || !Bounded(row.Provider, 100) || !CanonicalGuid(row.ProviderConfigurationId, out configurationId) || !Bounded(row.MappingIdentity, 200) || !CanonicalGuid(row.TransportProfileId, out profileId) || !HostedOpaqueIdentity.IsValid(row.SelectedProfileAuthorityIdentity) + || row.ProviderMode is not null && !Bounded(row.ProviderMode, 100) || !Bounded(row.ModeKey, 100) || !Bounded(row.Category, 100) || row.StorageAuthority != "persistent" || !row.IsCurrentAuthority || !ValidCanonicalCoordinate(row.OriginLongitude, row.OriginLatitude) diff --git a/src/WayfarerMobile/Services/HostedRoutingModels.cs b/src/WayfarerMobile/Services/HostedRoutingModels.cs index 6360032..031aded 100644 --- a/src/WayfarerMobile/Services/HostedRoutingModels.cs +++ b/src/WayfarerMobile/Services/HostedRoutingModels.cs @@ -31,7 +31,7 @@ public static HostedRoutingCapability Available(Guid profileId, string catalogId public sealed record HostedRouteRequest(Guid TransportProfileId, HostedRouteCoordinate Origin, HostedRouteCoordinate Destination, IReadOnlyList Anchors, - string SelectedProfileAuthorityIdentity, string ProviderMode = "walk"); + string SelectedProfileAuthorityIdentity, string ProviderMode); public sealed record HostedRouteResponse(bool Succeeded, string Outcome, IReadOnlyList? Geometry, double? DistanceMetres, double? DurationSeconds, IReadOnlyList? Instructions, @@ -58,7 +58,7 @@ public sealed record HostedRouteCapabilityMetadata(string Provider, Guid Provide public sealed record HostedRouteCandidate(NavigationRoute Route, HostedRouteRequestContext Context, Guid SelectedProfileId, string SelectedProfileAuthorityIdentity, - HostedRouteCapabilityMetadata Metadata, DateTimeOffset GeneratedAt, string SelectedProviderMode = "walk"); + HostedRouteCapabilityMetadata Metadata, DateTimeOffset GeneratedAt, string SelectedProviderMode); public sealed record HostedRouteRequestContext(Guid? SavedTransportProfileId, string? ModeKey, string? Category, HostedRouteCoordinate Origin, HostedRouteCoordinate Destination, IReadOnlyList Anchors, diff --git a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs index d0c02f6..89cee36 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -408,7 +408,8 @@ await _retainedRouting.SaveAsync(result.Candidate, partition, DateTimeOffset.Utc else _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); return null; } - if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation) return null; + if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation + || !IsRequestCurrent(context, partition)) return null; var choiceContext = context with { ExpectedCatalogIdentity = result.DiscoveryCatalogIdentity, @@ -439,8 +440,8 @@ private async Task ResolveRetainedChoiceAsync(NavigationR if (choice == "Use retained route") { if (HostedRoutePublication.TryPublishRetained(retained, target)) - _hostedRouting.SelectRetained(context.Generation, provenance.TransportProfileId, - provenance.ProviderMode ?? string.Empty, provenance.SelectedProfileAuthorityIdentity); + _hostedRouting.SelectRetained(context.Generation, provenance.TransportProfileId, + provenance.ProviderMode ?? string.Empty, provenance.SelectedProfileAuthorityIdentity); return new(true, null); } if (choice != "Refresh with Wayfarer") diff --git a/tests/WayfarerMobile.Tests/Unit/Repositories/RetainedWayfarerRouteRepositoryTests.cs b/tests/WayfarerMobile.Tests/Unit/Repositories/RetainedWayfarerRouteRepositoryTests.cs index a2830a7..d4e982a 100644 --- a/tests/WayfarerMobile.Tests/Unit/Repositories/RetainedWayfarerRouteRepositoryTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Repositories/RetainedWayfarerRouteRepositoryTests.cs @@ -43,6 +43,7 @@ public async Task PersistentRoute_RoundTripsAcrossRecreation_AndRemainsPartition retained.Should().NotBeNull(); retained!.Route.Waypoints.Should().ContainSingle(point => point.Longitude == 23.005); retained.Route.HostedProvenance!.IsRetained.Should().BeTrue(); + retained.Route.HostedProvenance.ProviderMode.Should().Be("walk"); retained.Route.HostedProvenance.Age.Should().Be(TimeSpan.FromDays(30) + TimeSpan.FromMinutes(5)); otherAccount.Should().BeNull(); } @@ -414,7 +415,7 @@ private static HostedRouteCandidate Candidate(string instruction, double middleL }; return new(route, context, ProfileId, AuthorityIdentity, metadata ?? new("geoapify", ConfigurationId, "mapping-v1", "persistent"), - generatedAt ?? new DateTimeOffset(2026, 8, 31, 7, 55, 0, TimeSpan.Zero)); + generatedAt ?? new DateTimeOffset(2026, 8, 31, 7, 55, 0, TimeSpan.Zero), "walk"); } private static HostedRouteRequestContext Context() => new(ProfileId, "walk", "active", diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs index 9f6debd..00528d1 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutePublicationTests.cs @@ -62,7 +62,8 @@ public void CandidatePublishesOnlyWhenAllLiveAuthorityStillMatches() candidate.Metadata.ProviderConfigurationId, candidate.Metadata.MappingIdentity, candidate.Metadata.StorageMode, - candidate.GeneratedAt)); + candidate.GeneratedAt, + candidate.SelectedProviderMode)); } private static HostedRouteCandidate Candidate() @@ -73,7 +74,7 @@ private static HostedRouteCandidate Candidate() profileId, "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", new("geoapify", Guid.Parse("22222222-2222-2222-2222-222222222222"), "mapping", "persistent"), - DateTimeOffset.UtcNow); + DateTimeOffset.UtcNow, "walk"); } private static HostedRouteLiveAuthority Live(HostedRouteCandidate candidate) => new( @@ -90,7 +91,19 @@ private static HostedRouteCandidate Candidate() candidate.Context.Category, candidate.SelectedProfileId, candidate.SelectedProfileAuthorityIdentity, - candidate.Context.NavigationChoice); + candidate.Context.NavigationChoice, + candidate.SelectedProviderMode); + + [Fact] + public void CandidateCannotPublishForDifferentProviderMode() + { + var direct = DirectRoute(); + var candidate = Candidate(); + + HostedRoutePublication.TryPublish(candidate, + Live(candidate) with { SelectedProviderMode = "drive" }, direct).Should().BeFalse(); + direct.IsDirectRoute.Should().BeTrue(); + } private static NavigationRoute DirectRoute() => new() { diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs index dd031be..eaf0caf 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingApiClientTests.cs @@ -66,7 +66,7 @@ var path when path.StartsWith("/api/mobile/routing/capability/") => $$"""{"outco var capability = await client.GetCapabilityAsync( profileId, "walk", catalog.DiscoveryCatalogIdentity!, default); var route = await client.GetRouteAsync(new(profileId, new(23, 37), new(23.01, 37.01), [], - capability.SelectedProfileAuthorityIdentity!), default); + capability.SelectedProfileAuthorityIdentity!, "walk"), default); route.Succeeded.Should().BeTrue(); route.Provider.Should().Be("geoapify"); @@ -148,7 +148,7 @@ public async Task Route400InvalidRequest_IsReturnedAsTerminalOutcome() """{"succeeded":false,"outcome":"invalid-request"}""")))); var route = await client.GetRouteAsync(new(profileId, new(23, 37), new(23.01, 37.01), [], - "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), default); + "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "walk"), default); route.Outcome.Should().Be("invalid-request"); route.Succeeded.Should().BeFalse(); @@ -175,7 +175,8 @@ public async Task RouteGeneratedAt_RequiresExplicitOffsetAndNormalizesUtc(string }); 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); + var route = await client.GetRouteAsync( + new(profileId, new(23, 37), new(23.01, 37.01), [], identity, "walk"), default); if (valid) route.GeneratedAt.Should().Be(new DateTimeOffset(2026, 8, 30, 10, 0, 0, TimeSpan.Zero)); diff --git a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs index 011c7c0..56e14e8 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/HostedRoutingServiceTests.cs @@ -72,7 +72,7 @@ public void Publication_SelectedAuthorityChangeBeforePublication_DiscardsCandida var context = HostedRouteRequestContext.ForTest(WalkingProfile); var candidate = new HostedRouteCandidate(new WayfarerMobile.Core.Models.NavigationRoute(), context, WalkingProfile, IdentityA, - new("geoapify", CyclingProfile, "mapping", "persistent"), DateTimeOffset.UtcNow); + new("geoapify", CyclingProfile, "mapping", "persistent"), DateTimeOffset.UtcNow, "walk"); var live = new HostedRouteLiveAuthority(context.Generation, context.AuthenticationSessionRevision, context.NormalizedServer, context.Origin, context.Destination, context.Anchors, context.TargetAssociation, context.SegmentId, context.SavedTransportProfileId, diff --git a/tests/WayfarerMobile.Tests/Unit/Services/RetainedWayfarerRouteMigrationTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/RetainedWayfarerRouteMigrationTests.cs index c048b5b..536d7f5 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/RetainedWayfarerRouteMigrationTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/RetainedWayfarerRouteMigrationTests.cs @@ -46,7 +46,9 @@ await RetainedWayfarerRouteMigration.ApplyApplicationUpgradeAsync(connection, .Should().Be("preserve-me"); (await connection.ExecuteScalarAsync( "SELECT Value FROM AppSettings WHERE Key = 'db_schema_version'")) - .Should().Be("10", "the application schema owner records the completed migration"); + .Should().Be("11", "the application schema owner records the completed migration"); + var columns = await connection.GetTableInfoAsync("RetainedWayfarerRoutes"); + columns.Select(column => column.Name).Should().Contain("ProviderMode"); await connection.CloseAsync(); } finally diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs index 580d00b..04f7d8e 100644 --- a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs @@ -356,7 +356,7 @@ public async Task FreshValidatedRoute_RemainsPublishedWhenLocalPersistenceFails( }; var candidate = new HostedRouteCandidate(route, context, WalkingProfile, IdentityA, new("geoapify", Guid.Parse("22222222-2222-2222-2222-222222222222"), - "mapping", "persistent"), DateTimeOffset.UtcNow.AddMinutes(-1)); + "mapping", "persistent"), DateTimeOffset.UtcNow.AddMinutes(-1), "walk"); (await repository.SaveAsync(candidate, settings.RoutingAccountPartition, DateTimeOffset.UtcNow, () => true)).Should().Be(RetainedRouteSaveResult.Saved); return (repository, new(repository, @@ -429,4 +429,78 @@ private static IDialogService SelectedModeDialogs() .ReturnsAsync("Walk"); return dialogs.Object; } + + [Fact] + public async Task FreshRoute_ShowsOnlyBackendModesAndCancellationMakesNoFurtherContact() + { + var modes = new[] + { + new HostedProviderMode("walk", "Walk"), new("bicycle", "Bicycle"), + new("motorcycle", "Motorcycle"), new("drive", "Drive"), new("bus", "Bus") + }; + var api = new Mock(MockBehavior.Strict); + api.Setup(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(Catalog(IdentityA, modes)); + var dialogs = new Mock(MockBehavior.Strict); + dialogs.Setup(service => service.SelectAsync( + "Provider route mode (separate from the Segment Transport Profile)", + It.Is>(choices => choices.SequenceEqual( + new[] { "Walk", "Bicycle", "Motorcycle", "Drive", "Bus" })), "Direct")) + .ReturnsAsync((string?)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", "car"); + + route.Should().BeSameAs(navigation.ActiveRoute); + route.IsDirectRoute.Should().BeTrue(); + api.Verify(client => client.GetCapabilityAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + api.Verify(client => client.GetRouteAsync(It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task ExplicitDirect_MakesNoHostedOrChooserContact() + { + var api = new Mock(MockBehavior.Strict); + var dialogs = new Mock(MockBehavior.Strict); + var (coordinator, _, _, _) = CreateCoordinator(api.Object, dialogs.Object); + + var route = await coordinator.CalculateRouteToCoordinatesAsync( + 37, 23, 37.01, 23.01, "Target", "direct"); + + route.IsDirectRoute.Should().BeTrue(); + api.VerifyNoOtherCalls(); + dialogs.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AuthenticationChangesWhileChoosing_PreventCapabilityContact() + { + var settings = new MockSettingsService(); + var api = new Mock(MockBehavior.Strict); + api.Setup(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(Catalog(IdentityA, new HostedProviderMode("walk", "Walk"))); + var dialogs = new Mock(MockBehavior.Strict); + dialogs.Setup(service => service.SelectAsync( + "Provider route mode (separate from the Segment Transport Profile)", + It.IsAny>(), "Direct")) + .Callback(() => settings.ApiToken = "replacement-token") + .ReturnsAsync("Walk"); + var (coordinator, navigation, _, callbacks) = CreateCoordinator( + api.Object, dialogs.Object, suppliedSettings: settings); + 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(); + api.Verify(client => client.GetCapabilityAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } } From d93a9446093d49935d6447e8d10e4775e69748a0 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Thu, 3 Sep 2026 20:44:01 +0300 Subject: [PATCH 3/5] fix(navigation): cancel dismissed provider chooser --- .../Interfaces/ITripNavigationService.cs | 7 ++- .../Services/HostedRoutingService.cs | 11 ++++ .../Services/TripNavigationService.cs | 12 +++-- .../ViewModels/ContextMenuViewModel.cs | 2 + .../ViewModels/MemberDetailsViewModel.cs | 2 + .../NavigationCoordinatorViewModel.cs | 51 +++++++++++++----- .../Mocks/MockTripNavigationService.cs | 7 ++- ...NavigationCoordinatorHostedRoutingTests.cs | 53 ++++++++++++++++--- 8 files changed, 115 insertions(+), 30 deletions(-) diff --git a/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs b/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs index 0c212d6..0edc42e 100644 --- a/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs +++ b/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs @@ -95,12 +95,17 @@ public interface ITripNavigationService /// Destination longitude. /// Destination name for display. /// Routing profile (foot, car, bike). Default is foot. + /// Whether to replace the active navigation route. /// The Direct route. Task CalculateRouteToCoordinatesAsync( double currentLat, double currentLon, double destLat, double destLon, string destName, - string profile = "foot"); + string profile = "foot", + bool activate = true); + + /// Installs a route selected by a coordinator. + void ActivateRoute(NavigationRoute route); /// /// Calculates a route to the next place in sequence. diff --git a/src/WayfarerMobile/Services/HostedRoutingService.cs b/src/WayfarerMobile/Services/HostedRoutingService.cs index eebd6b1..aacfe55 100644 --- a/src/WayfarerMobile/Services/HostedRoutingService.cs +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -112,6 +112,17 @@ public void SelectRetained(long generation, Guid profileId, string providerMode, } } + /// Restores the selection that preceded a dismissed chooser. + public void RestoreSelection(HostedRouteSelection? selection) + { + lock (stateLock) + { + activeGeneration = selection?.Generation ?? 0; + currentSelection = selection; + IsLoading = false; + } + } + private bool Begin(HostedRouteRequestContext context) { lock (stateLock) diff --git a/src/WayfarerMobile/Services/TripNavigationService.cs b/src/WayfarerMobile/Services/TripNavigationService.cs index fd4c465..76cdb5c 100644 --- a/src/WayfarerMobile/Services/TripNavigationService.cs +++ b/src/WayfarerMobile/Services/TripNavigationService.cs @@ -203,7 +203,6 @@ public void StopNavigation() /// /// Calculates a route to arbitrary coordinates (not requiring a loaded trip). - /// Direct guidance is a straight line with distance, bearing, and profile-aware ETA. /// /// Current latitude. /// Current longitude. @@ -211,20 +210,23 @@ public void StopNavigation() /// Destination longitude. /// Destination name for display. /// Routing profile (foot, car, bike). Default is foot. + /// Whether to replace the active navigation route. /// The Direct route. public Task CalculateRouteToCoordinatesAsync( double currentLat, double currentLon, double destLat, double destLon, string destName, - string profile = "foot") + string profile = "foot", + bool activate = true) { - _logger.LogInformation("Calculating Direct guidance to {Name}", destName); - _logger.LogInformation("Using direct route to {Name} with profile {Profile}", destName, profile); var directRoute = _routeBuilder.BuildDirectRouteToCoordinates(currentLat, currentLon, destLat, destLon, destName, profile); - InstallRoute(directRoute, destinationPlaceId: null); + if (activate) InstallRoute(directRoute, destinationPlaceId: null); return Task.FromResult(directRoute); } + /// + public void ActivateRoute(NavigationRoute route) => InstallRoute(route, destinationPlaceId: null); + /// /// Calculates a route to the next place in sequence. /// diff --git a/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs b/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs index 8819255..ee52f37 100644 --- a/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs +++ b/src/WayfarerMobile/ViewModels/ContextMenuViewModel.cs @@ -232,6 +232,8 @@ private async Task NavigateToContextLocationAsync() "Dropped Pin", travelProfile); + if (route == null) return; + // Clear dropped pin and start navigation ClearDroppedPin(); diff --git a/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs index 1058262..4f9188c 100644 --- a/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs +++ b/src/WayfarerMobile/ViewModels/MemberDetailsViewModel.cs @@ -340,6 +340,8 @@ await OpenExternalMapsAsync( $"group-member:{targetUserId}", () => ResolveCurrentMemberLocation(targetUserId)); + if (route == null) return; + // Close bottom sheet before navigating IsMemberSheetOpen = false; diff --git a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs index 89cee36..ee5a85a 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -313,9 +313,11 @@ public async Task CalculateRouteToCoordinatesAsync( fromLat, fromLon, toLat, toLon, destinationName, - profile); - return await TryHostedAsync(direct, fromLat, fromLon, toLat, toLon, destinationName, + profile, activate: false); + var route = await TryHostedAsync(direct, fromLat, fromLon, toLat, toLon, destinationName, profile, null, HostedRouteTargetOwner.Fixed(toLat, toLon, "ad-hoc-coordinates")); + if (route != null) _tripNavigationService.ActivateRoute(route); + return route!; } /// Routes a non-Trip target through the shared hosted coordinator path. @@ -324,15 +326,18 @@ public async Task CalculateHostedRouteToCoordinatesAsync( 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, + fromLat, fromLon, toLat, toLon, destinationName, profile, activate: false); + var route = await TryHostedAsync(direct, fromLat, fromLon, toLat, toLon, destinationName, profile, null, HostedRouteTargetOwner.Member(toLat, toLon, targetAssociation, currentTarget)); + if (route != null) _tripNavigationService.ActivateRoute(route); + return route!; } - private async Task TryHostedAsync(NavigationRoute direct, double fromLat, double fromLon, + private async Task TryHostedAsync(NavigationRoute direct, double fromLat, double fromLon, double toLat, double toLon, string destinationName, string profile, HostedTripTargetAuthority? tripAuthority, HostedRouteTargetOwner targetOwner) { + var priorSelection = _hostedRouting.CurrentSelection; var generation = Interlocked.Increment(ref _hostedRoutingGeneration); CancelHostedRouting(incrementGeneration: false); if (profile == "direct") @@ -349,8 +354,8 @@ private async Task TryHostedAsync(NavigationRoute direct, doubl var retainedDecision = await ResolveRetainedChoiceAsync(direct, context, partition); if (retainedDecision.RouteComplete) return direct; var retainedFallback = retainedDecision.RefreshFallback; - var result = await RequestFreshRouteAsync(context, generation, partition, retainedFallback); - if (result == null) return direct; + var result = await RequestFreshRouteAsync(context, generation, partition, retainedFallback, priorSelection); + if (result == null) return null; if (result.Outcome != HostedRoutingOutcome.Success || result.Candidate == null) { if (retainedFallback != null) @@ -377,7 +382,8 @@ await _retainedRouting.SaveAsync(result.Candidate, partition, DateTimeOffset.Utc } private async Task RequestFreshRouteAsync(HostedRouteRequestContext context, - long generation, Guid partition, HostedRouteProvenance? retainedFallback) + long generation, Guid partition, HostedRouteProvenance? retainedFallback, + HostedRouteSelection? priorSelection) { var catalogRediscoveryAvailable = true; var result = await _hostedRouting.RequestRouteAsync(context, @@ -395,21 +401,28 @@ await _retainedRouting.SaveAsync(result.Candidate, partition, DateTimeOffset.Utc { if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation || result.Choices is not { Count: > 0 } - || !HostedOpaqueIdentity.IsValid(result.DiscoveryCatalogIdentity)) return null; + || !HostedOpaqueIdentity.IsValid(result.DiscoveryCatalogIdentity)) + return new(HostedRoutingOutcome.Unavailable); var options = result.Choices.Select(item => item.Label).ToArray(); var selected = await _dialogs.SelectAsync( "Provider route mode (separate from the Segment Transport Profile)", options, "Direct"); - var index = selected == null ? -1 : Array.IndexOf(options, selected); - if (index < 0) + if (selected == null) + { + RestoreChooserSelection(context, partition, retainedFallback, priorSelection); + return null; + } + if (selected == "Direct") { if (retainedFallback != null) RestoreRetainedSelection(context, partition, retainedFallback); else _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); - return null; + return new(HostedRoutingOutcome.Cancelled); } + var index = Array.IndexOf(options, selected); + if (index < 0) return new(HostedRoutingOutcome.Unavailable); if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation - || !IsRequestCurrent(context, partition)) return null; + || !IsRequestCurrent(context, partition)) return new(HostedRoutingOutcome.Unavailable); var choiceContext = context with { ExpectedCatalogIdentity = result.DiscoveryCatalogIdentity, @@ -425,7 +438,7 @@ await _retainedRouting.SaveAsync(result.Candidate, partition, DateTimeOffset.Utc if (retainedFallback != null) RestoreRetainedSelection(context, partition, retainedFallback); else _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); - return null; + return new(HostedRoutingOutcome.Unavailable); } private async Task ResolveRetainedChoiceAsync(NavigationRoute target, @@ -473,6 +486,16 @@ private void RestoreRetainedSelection(HostedRouteRequestContext context, Guid pa retained.ProviderMode ?? string.Empty, retained.SelectedProfileAuthorityIdentity); } + private void RestoreChooserSelection(HostedRouteRequestContext context, Guid partition, + HostedRouteProvenance? retainedFallback, HostedRouteSelection? priorSelection) + { + if (retainedFallback != null) + RestoreRetainedSelection(context, partition, retainedFallback); + else + _hostedRouting.RestoreSelection(priorSelection); + CancelHostedRouting(incrementGeneration: false); + } + private HostedRouteRequestContext CreateHostedContext(double fromLat, double fromLon, double toLat, double toLon, string destinationName, string profile, long generation, HostedTripTargetAuthority? tripAuthority, string targetAssociation) diff --git a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs index 4c76cab..d483c6c 100644 --- a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs +++ b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs @@ -135,7 +135,7 @@ public void UnloadTrip() /// public Task CalculateRouteToCoordinatesAsync(double currentLat, double currentLon, - double destLat, double destLon, string destName, string profile = "foot") + double destLat, double destLon, string destName, string profile = "foot", bool activate = true) { var route = _nextRouteToReturn ?? new NavigationRoute { @@ -144,10 +144,13 @@ public Task CalculateRouteToCoordinatesAsync(double currentLat, EstimatedDuration = TimeSpan.FromSeconds(600), IsDirectRoute = true }; - _activeRoute = route; + if (activate) _activeRoute = route; return Task.FromResult(route); } + /// + public void ActivateRoute(NavigationRoute route) => _activeRoute = route; + /// public NavigationRoute? CalculateRouteToNextPlace(double currentLat, double currentLon) { diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs index 04f7d8e..309d81c 100644 --- a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs @@ -17,6 +17,7 @@ public sealed class NavigationCoordinatorHostedRoutingTests : IAsyncLifetime private const string IdentityB = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ"; private readonly List retainedConnections = []; private readonly List retainedDatabasePaths = []; + private HostedRoutingService? lastHostedRouting; public Task InitializeAsync() => Task.CompletedTask; @@ -30,7 +31,7 @@ public async Task DisposeAsync() } [Fact] - public async Task OpenChooser_CatalogChanges_SubmitsDisplayedIdentityThenRefreshesBeforeReselection() + public async Task OpenChooser_CatalogChangesThenDismissalReturnsNoRoute() { var catalogA = Catalog(IdentityA, new HostedProviderMode("walk", "Walk"), new HostedProviderMode("bicycle", "Bicycle")); @@ -61,9 +62,8 @@ public async Task OpenChooser_CatalogChanges_SubmitsDisplayedIdentityThenRefresh 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(); + route.Should().BeNull(); + navigation.ActiveRoute.Should().BeNull(); presentations.Should().HaveCount(2); presentations[0].Should().Equal("Walk", "Bicycle"); presentations[1].Should().Equal("On foot"); @@ -376,11 +376,13 @@ public async Task FreshValidatedRoute_RemainsPublishedWhenLocalPersistenceFails( new NavigationRouteBuilder(NullLogger.Instance), state); var settings = suppliedSettings ?? new MockSettingsService(); + var hostedRouting = new HostedRoutingService(api, NullLogger.Instance); + lastHostedRouting = hostedRouting; var coordinator = new NavigationCoordinatorViewModel( navigation, new NavigationHudViewModel(), Mock.Of(), - new HostedRoutingService(api, NullLogger.Instance), + hostedRouting, retainedRouting ?? CreateRetainedRoutingService(), settings, dialogs, @@ -431,7 +433,7 @@ private static IDialogService SelectedModeDialogs() } [Fact] - public async Task FreshRoute_ShowsOnlyBackendModesAndCancellationMakesNoFurtherContact() + public async Task FreshRoute_DismissedChooserReturnsNoRouteAndPreservesCurrentNavigation() { var modes = new[] { @@ -451,11 +453,46 @@ public async Task FreshRoute_ShowsOnlyBackendModesAndCancellationMakesNoFurtherC callbacks.SetupGet(value => value.CurrentLocation) .Returns(new LocationData { Latitude = 37, Longitude = 23 }); + var priorRoute = await coordinator.CalculateRouteToCoordinatesAsync( + 37, 23, 37.005, 23.005, "Existing", "direct"); + coordinator.IsNavigating = true; + lastHostedRouting!.SelectRetained(1, WalkingProfile, "walk", IdentityA); + var priorSelection = lastHostedRouting!.CurrentSelection; + + var route = await coordinator.CalculateRouteToCoordinatesAsync( + 37, 23, 37.01, 23.01, "Target", "car"); + + route.Should().BeNull(); + navigation.ActiveRoute.Should().BeSameAs(priorRoute); + coordinator.IsNavigating.Should().BeTrue(); + priorSelection!.ProviderMode.Should().Be("walk"); + lastHostedRouting.CurrentSelection.Should().Be(priorSelection); + api.Verify(client => client.GetCapabilityAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + api.Verify(client => client.GetRouteAsync(It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task FreshRoute_ExplicitDirectReturnsDirectWithoutProviderContact() + { + var api = new Mock(MockBehavior.Strict); + api.Setup(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(Catalog(IdentityA, new HostedProviderMode("walk", "Walk"))); + var dialogs = new Mock(MockBehavior.Strict); + dialogs.Setup(service => service.SelectAsync( + "Provider route mode (separate from the Segment Transport Profile)", + It.Is>(choices => choices.SequenceEqual(new[] { "Walk" })), "Direct")) + .ReturnsAsync("Direct"); + 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", "car"); route.Should().BeSameAs(navigation.ActiveRoute); - route.IsDirectRoute.Should().BeTrue(); + route!.IsDirectRoute.Should().BeTrue(); api.Verify(client => client.GetCapabilityAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); api.Verify(client => client.GetRouteAsync(It.IsAny(), @@ -472,7 +509,7 @@ public async Task ExplicitDirect_MakesNoHostedOrChooserContact() var route = await coordinator.CalculateRouteToCoordinatesAsync( 37, 23, 37.01, 23.01, "Target", "direct"); - route.IsDirectRoute.Should().BeTrue(); + route!.IsDirectRoute.Should().BeTrue(); api.VerifyNoOtherCalls(); dialogs.VerifyNoOtherCalls(); } From 5310955a11a5aba6b7f929a4bb21a33c7951b0ed Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Thu, 3 Sep 2026 21:08:11 +0300 Subject: [PATCH 4/5] fix(navigation): fence dismissed provider choosers --- .../Interfaces/ITripNavigationService.cs | 5 +- .../Services/HostedRoutingService.cs | 32 ++-- .../Services/TripNavigationService.cs | 12 +- .../ViewModels/IContextMenuCallbacks.cs | 2 +- .../ViewModels/MainViewModel.cs | 2 +- .../NavigationCoordinatorViewModel.cs | 88 +++++------ .../Mocks/MockTripNavigationService.cs | 12 +- ...NavigationCoordinatorHostedRoutingTests.cs | 99 ++++++++++++- .../NavigationCoordinatorTripChooserTests.cs | 137 ++++++++++++++++++ 9 files changed, 304 insertions(+), 85 deletions(-) create mode 100644 tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorTripChooserTests.cs diff --git a/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs b/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs index 0edc42e..2967600 100644 --- a/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs +++ b/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs @@ -67,7 +67,8 @@ public interface ITripNavigationService /// Current longitude. /// Destination place ID. /// The calculated route or null if no route found. - NavigationRoute? CalculateRouteToPlace(double currentLat, double currentLon, string destinationPlaceId); + NavigationRoute? CalculateRouteToPlace(double currentLat, double currentLon, string destinationPlaceId, + bool activate = true); /// /// Calculates a route to a specific place using saved Segment geometry or Direct guidance. @@ -113,7 +114,7 @@ Task CalculateRouteToCoordinatesAsync( /// Current latitude. /// Current longitude. /// The calculated route or null if no next place. - NavigationRoute? CalculateRouteToNextPlace(double currentLat, double currentLon); + NavigationRoute? CalculateRouteToNextPlace(double currentLat, double currentLon, bool activate = true); /// /// Updates navigation state with current location. diff --git a/src/WayfarerMobile/Services/HostedRoutingService.cs b/src/WayfarerMobile/Services/HostedRoutingService.cs index aacfe55..61f2129 100644 --- a/src/WayfarerMobile/Services/HostedRoutingService.cs +++ b/src/WayfarerMobile/Services/HostedRoutingService.cs @@ -66,9 +66,7 @@ public async Task RequestRouteAsync(HostedRouteRequestConte selectedMode.Key); var response = await api.GetRouteAsync(request, cancellationToken); if (!ValidResponse(response, request, capability)) return new(HostedRoutingOutcome.InvalidResponse); - if (!SelectCurrent(context.Generation, profileId, selectedMode.Key, - capability.SelectedProfileAuthorityIdentity!)) - return new(HostedRoutingOutcome.Stale); + if (!IsCurrentGeneration(context.Generation)) 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, @@ -112,39 +110,35 @@ public void SelectRetained(long generation, Guid profileId, string providerMode, } } - /// Restores the selection that preceded a dismissed chooser. - public void RestoreSelection(HostedRouteSelection? selection) + /// Commits a validated candidate only while its invocation still owns service state. + public bool TrySelectCandidate(HostedRouteCandidate candidate, HostedRouteSelection? expectedSelection) { lock (stateLock) { - activeGeneration = selection?.Generation ?? 0; - currentSelection = selection; - IsLoading = false; + if (activeGeneration != candidate.Context.Generation + || !Equals(currentSelection, expectedSelection)) return false; + currentSelection = new(candidate.Context.Generation, candidate.SelectedProfileId, + candidate.SelectedProviderMode, candidate.SelectedProfileAuthorityIdentity); + return true; } } + private bool IsCurrentGeneration(long generation) + { + lock (stateLock) return activeGeneration == generation; + } + private bool Begin(HostedRouteRequestContext context) { lock (stateLock) { if (context.Generation < activeGeneration) return false; activeGeneration = context.Generation; - currentSelection = null; IsLoading = true; return true; } } - private bool SelectCurrent(long generation, Guid profileId, string providerMode, string authorityIdentity) - { - lock (stateLock) - { - if (activeGeneration != generation) return false; - currentSelection = new(generation, profileId, providerMode, authorityIdentity); - return true; - } - } - private async Task RefreshCatalogAsync(CancellationToken cancellationToken) { var catalog = await api.DiscoverAsync(cancellationToken); diff --git a/src/WayfarerMobile/Services/TripNavigationService.cs b/src/WayfarerMobile/Services/TripNavigationService.cs index 76cdb5c..f646f88 100644 --- a/src/WayfarerMobile/Services/TripNavigationService.cs +++ b/src/WayfarerMobile/Services/TripNavigationService.cs @@ -143,7 +143,8 @@ public void StopNavigation() /// Current longitude. /// Destination place ID. /// The calculated route or null if no route found. - public NavigationRoute? CalculateRouteToPlace(double currentLat, double currentLon, string destinationPlaceId) + public NavigationRoute? CalculateRouteToPlace(double currentLat, double currentLon, + string destinationPlaceId, bool activate = true) { if (_currentGraph == null) { @@ -170,16 +171,15 @@ public void StopNavigation() if (path.Count > 0) { var segmentRoute = _routeBuilder.BuildFromSegmentPath(path, currentLat, currentLon, _currentGraph); - InstallRoute(segmentRoute, destinationPlaceId); + if (activate) InstallRoute(segmentRoute, destinationPlaceId); _logger.LogDebug("Using segment route with {WaypointCount} waypoints", segmentRoute.Waypoints.Count); return segmentRoute; } } } - // Priority 2: Direct navigation (bearing + distance) var directRoute = _routeBuilder.BuildDirectRoute(currentLat, currentLon, destination); - InstallRoute(directRoute, destinationPlaceId); + if (activate) InstallRoute(directRoute, destinationPlaceId); _logger.LogDebug("Using direct route to {Destination}", destination.Name); return directRoute; } @@ -233,7 +233,7 @@ public Task CalculateRouteToCoordinatesAsync( /// Current latitude. /// Current longitude. /// The calculated route or null if no next place. - public NavigationRoute? CalculateRouteToNextPlace(double currentLat, double currentLon) + public NavigationRoute? CalculateRouteToNextPlace(double currentLat, double currentLon, bool activate = true) { var nextPlace = GetNextPlaceInSequence(currentLat, currentLon); if (nextPlace == null) @@ -242,7 +242,7 @@ public Task CalculateRouteToCoordinatesAsync( return null; } - return CalculateRouteToPlace(currentLat, currentLon, nextPlace.Id.ToString()); + return CalculateRouteToPlace(currentLat, currentLon, nextPlace.Id.ToString(), activate); } /// diff --git a/src/WayfarerMobile/ViewModels/IContextMenuCallbacks.cs b/src/WayfarerMobile/ViewModels/IContextMenuCallbacks.cs index 9151c5e..b63a212 100644 --- a/src/WayfarerMobile/ViewModels/IContextMenuCallbacks.cs +++ b/src/WayfarerMobile/ViewModels/IContextMenuCallbacks.cs @@ -43,7 +43,7 @@ public interface IContextMenuCallbacks /// /// Calculates a route to the specified coordinates. /// - Task CalculateRouteToCoordinatesAsync( + Task CalculateRouteToCoordinatesAsync( double fromLat, double fromLon, double toLat, double toLon, string destinationName, diff --git a/src/WayfarerMobile/ViewModels/MainViewModel.cs b/src/WayfarerMobile/ViewModels/MainViewModel.cs index ec375a2..f3680a6 100644 --- a/src/WayfarerMobile/ViewModels/MainViewModel.cs +++ b/src/WayfarerMobile/ViewModels/MainViewModel.cs @@ -719,7 +719,7 @@ void IContextMenuCallbacks.ClearDroppedPinFromMap() => MapDisplay.ClearDroppedPin(); /// - Task IContextMenuCallbacks.CalculateRouteToCoordinatesAsync( + Task IContextMenuCallbacks.CalculateRouteToCoordinatesAsync( double fromLat, double fromLon, double toLat, double toLon, string destinationName, string profile) => Navigation.CalculateRouteToCoordinatesAsync(fromLat, fromLon, toLat, toLon, destinationName, profile); diff --git a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs index ee5a85a..5148091 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -141,19 +141,20 @@ public async Task StartNavigationToPlaceAsync(string placeId) return; } - CancelHostedRouting(); - var route = _tripNavigationService.CalculateRouteToPlace( currentLocation.Latitude, currentLocation.Longitude, - placeId); + placeId, + activate: false); + var hostedAttempted = false; if (route?.IsDirectRoute == true && Guid.TryParse(placeId, out var destinationId)) { var authority = HostedTripTargetAuthority.Resolve(_tripState.LoadedTrip, destinationId, currentLocation.Latitude, currentLocation.Longitude); if (authority != null) { + hostedAttempted = true; route = await TryHostedAsync(route, currentLocation.Latitude, currentLocation.Longitude, authority.Destination.Latitude, authority.Destination.Longitude, route.DestinationName, authority.ModeKey, authority, HostedRouteTargetOwner.Trip(destinationId)); @@ -162,6 +163,8 @@ public async Task StartNavigationToPlaceAsync(string placeId) if (route != null) { + if (!hostedAttempted) CancelHostedRouting(); + _tripNavigationService.ActivateRoute(route); // Track navigation destination for visit notification conflict detection _currentNavigationPlaceId = Guid.TryParse(placeId, out var guid) ? guid : null; _visitNotificationService.UpdateNavigationState(true, _currentNavigationPlaceId); @@ -189,13 +192,13 @@ public async Task StartNavigationToNextAsync() return; } - CancelHostedRouting(); - var route = _tripNavigationService.CalculateRouteToNextPlace( currentLocation.Latitude, - currentLocation.Longitude); + currentLocation.Longitude, + activate: false); Guid? destinationPlaceId = null; + var hostedAttempted = false; if (route?.IsDirectRoute == true && route.Waypoints.Count > 0) { var destination = route.Waypoints[^1]; @@ -206,6 +209,7 @@ public async Task StartNavigationToNextAsync() : null; if (authority != null) { + hostedAttempted = true; route = await TryHostedAsync(route, currentLocation.Latitude, currentLocation.Longitude, authority.Destination.Latitude, authority.Destination.Longitude, route.DestinationName, authority.ModeKey, authority, HostedRouteTargetOwner.Trip(authority.DestinationPlaceId)); @@ -218,6 +222,8 @@ public async Task StartNavigationToNextAsync() if (route != null) { + if (!hostedAttempted) CancelHostedRouting(); + _tripNavigationService.ActivateRoute(route); // Track navigation destination for visit notification conflict detection _currentNavigationPlaceId = destinationPlaceId; _visitNotificationService.UpdateNavigationState(true, destinationPlaceId); @@ -303,7 +309,7 @@ public void UpdateLocation(double latitude, double longitude) /// /// Calculates a route to arbitrary coordinates (for non-trip navigation like dropped pins). /// - public async Task CalculateRouteToCoordinatesAsync( + public async Task CalculateRouteToCoordinatesAsync( double fromLat, double fromLon, double toLat, double toLon, string destinationName, @@ -317,11 +323,11 @@ public async Task CalculateRouteToCoordinatesAsync( var route = await TryHostedAsync(direct, fromLat, fromLon, toLat, toLon, destinationName, profile, null, HostedRouteTargetOwner.Fixed(toLat, toLon, "ad-hoc-coordinates")); if (route != null) _tripNavigationService.ActivateRoute(route); - return route!; + return route; } /// Routes a non-Trip target through the shared hosted coordinator path. - public async Task CalculateHostedRouteToCoordinatesAsync( + public async Task CalculateHostedRouteToCoordinatesAsync( double fromLat, double fromLon, double toLat, double toLon, string destinationName, string profile, string targetAssociation, Func currentTarget) { @@ -330,14 +336,13 @@ public async Task CalculateHostedRouteToCoordinatesAsync( var route = await TryHostedAsync(direct, fromLat, fromLon, toLat, toLon, destinationName, profile, null, HostedRouteTargetOwner.Member(toLat, toLon, targetAssociation, currentTarget)); if (route != null) _tripNavigationService.ActivateRoute(route); - return route!; + return route; } private async Task TryHostedAsync(NavigationRoute direct, double fromLat, double fromLon, double toLat, double toLon, string destinationName, string profile, HostedTripTargetAuthority? tripAuthority, HostedRouteTargetOwner targetOwner) { - var priorSelection = _hostedRouting.CurrentSelection; var generation = Interlocked.Increment(ref _hostedRoutingGeneration); CancelHostedRouting(incrementGeneration: false); if (profile == "direct") @@ -354,40 +359,44 @@ public async Task CalculateHostedRouteToCoordinatesAsync( var retainedDecision = await ResolveRetainedChoiceAsync(direct, context, partition); if (retainedDecision.RouteComplete) return direct; var retainedFallback = retainedDecision.RefreshFallback; - var result = await RequestFreshRouteAsync(context, generation, partition, retainedFallback, priorSelection); + var expectedSelection = _hostedRouting.CurrentSelection; + var cancellation = _hostedRoutingCancellation; + var result = await RequestFreshRouteAsync(context, generation, partition, retainedFallback, cancellation); if (result == null) return null; if (result.Outcome != HostedRoutingOutcome.Success || result.Candidate == null) { + if (!IsInvocationCurrent(context, partition, cancellation)) return null; if (retainedFallback != null) RestoreRetainedSelection(context, partition, retainedFallback); + else + _hostedRouting.SelectDirect(generation); return direct; } - if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation) return direct; + if (!IsInvocationCurrent(_hostedRequest!, partition, cancellation)) return null; var published = false; await MainThread.InvokeOnMainThreadAsync(() => { - var live = CreateLiveAuthority(); - if (live != null) published = HostedRoutePublication.TryPublish(result.Candidate, live, direct); + var candidateSelection = new HostedRouteSelection(result.Candidate.Context.Generation, + result.Candidate.SelectedProfileId, result.Candidate.SelectedProviderMode, + result.Candidate.SelectedProfileAuthorityIdentity); + var live = CreateLiveAuthority(selectionOverride: candidateSelection); + if (live != null && HostedRoutePublication.Current(result.Candidate, live) + && _hostedRouting.TrySelectCandidate(result.Candidate, expectedSelection)) + published = HostedRoutePublication.TryPublish(result.Candidate, live, direct); }); - if (published) - { - await _retainedRouting.SaveAsync(result.Candidate, partition, DateTimeOffset.UtcNow, - () => IsCandidateCurrent(result.Candidate, partition), _hostedRoutingCancellation.Token); - } - else if (retainedFallback != null) - { - RestoreRetainedSelection(context, partition, retainedFallback); - } + if (!published) return null; + await _retainedRouting.SaveAsync(result.Candidate, partition, DateTimeOffset.UtcNow, + () => IsCandidateCurrent(result.Candidate, partition), cancellation.Token); return direct; } private async Task RequestFreshRouteAsync(HostedRouteRequestContext context, long generation, Guid partition, HostedRouteProvenance? retainedFallback, - HostedRouteSelection? priorSelection) + CancellationTokenSource cancellation) { var catalogRediscoveryAvailable = true; var result = await _hostedRouting.RequestRouteAsync(context, - cancellationToken: _hostedRoutingCancellation!.Token); + cancellationToken: cancellation.Token); if (result.Outcome == HostedRoutingOutcome.CatalogChanged) catalogRediscoveryAvailable = false; var maximumPresentations = result.Outcome switch { @@ -409,14 +418,14 @@ await _retainedRouting.SaveAsync(result.Candidate, partition, DateTimeOffset.Utc "Provider route mode (separate from the Segment Transport Profile)", options, "Direct"); if (selected == null) { - RestoreChooserSelection(context, partition, retainedFallback, priorSelection); return null; } if (selected == "Direct") { if (retainedFallback != null) RestoreRetainedSelection(context, partition, retainedFallback); - else _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); + else if (IsInvocationCurrent(context, partition, cancellation)) + _hostedRouting.SelectDirect(generation); return new(HostedRoutingOutcome.Cancelled); } var index = Array.IndexOf(options, selected); @@ -430,14 +439,14 @@ await _retainedRouting.SaveAsync(result.Candidate, partition, DateTimeOffset.Utc }; _hostedRequest = choiceContext; result = await _hostedRouting.RequestRouteAsync( - choiceContext, result.Choices[index], _hostedRoutingCancellation.Token, + choiceContext, result.Choices[index], cancellation.Token, catalogRediscoveryAvailable); if (result.Outcome == HostedRoutingOutcome.CatalogChanged) catalogRediscoveryAvailable = false; } if (result.Outcome != HostedRoutingOutcome.CatalogChanged) return result; if (retainedFallback != null) RestoreRetainedSelection(context, partition, retainedFallback); - else _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); + else if (IsInvocationCurrent(context, partition, cancellation)) _hostedRouting.SelectDirect(generation); return new(HostedRoutingOutcome.Unavailable); } @@ -486,16 +495,6 @@ private void RestoreRetainedSelection(HostedRouteRequestContext context, Guid pa retained.ProviderMode ?? string.Empty, retained.SelectedProfileAuthorityIdentity); } - private void RestoreChooserSelection(HostedRouteRequestContext context, Guid partition, - HostedRouteProvenance? retainedFallback, HostedRouteSelection? priorSelection) - { - if (retainedFallback != null) - RestoreRetainedSelection(context, partition, retainedFallback); - else - _hostedRouting.RestoreSelection(priorSelection); - CancelHostedRouting(incrementGeneration: false); - } - private HostedRouteRequestContext CreateHostedContext(double fromLat, double fromLon, double toLat, double toLon, string destinationName, string profile, long generation, HostedTripTargetAuthority? tripAuthority, string targetAssociation) @@ -509,12 +508,13 @@ private HostedRouteRequestContext CreateHostedContext(double fromLat, double fro tripAuthority?.SegmentId); } - private HostedRouteLiveAuthority? CreateLiveAuthority(bool requireSelection = true) + private HostedRouteLiveAuthority? CreateLiveAuthority(bool requireSelection = true, + HostedRouteSelection? selectionOverride = null) { var request = _hostedRequest; var owner = _hostedTargetOwner; var location = _callbacks?.CurrentLocation; - var selection = _hostedRouting.CurrentSelection; + var selection = selectionOverride ?? _hostedRouting.CurrentSelection; if (request == null || owner == null || location == null || (requireSelection && selection?.Generation != _hostedRoutingGeneration)) return null; @@ -560,6 +560,10 @@ private bool IsCandidateCurrent(HostedRouteCandidate candidate, Guid partition) _settings.RoutingAccountPartition == partition && CreateLiveAuthority() is { } live && HostedRoutePublication.Current(candidate, live); + private bool IsInvocationCurrent(HostedRouteRequestContext context, Guid partition, + CancellationTokenSource cancellation) => ReferenceEquals(_hostedRoutingCancellation, cancellation) + && !cancellation.IsCancellationRequested && IsRequestCurrent(context, partition); + private void CancelHostedRouting(bool incrementGeneration = true) { if (incrementGeneration) _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration)); diff --git a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs index d483c6c..afebaf8 100644 --- a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs +++ b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs @@ -119,10 +119,10 @@ public void UnloadTrip() /// public NavigationRoute? CalculateRouteToPlace(double currentLat, double currentLon, - string destinationPlaceId) + string destinationPlaceId, bool activate = true) { - _activeRoute = _nextRouteToReturn; - return _activeRoute; + if (activate) _activeRoute = _nextRouteToReturn; + return _nextRouteToReturn; } /// @@ -152,10 +152,10 @@ public Task CalculateRouteToCoordinatesAsync(double currentLat, public void ActivateRoute(NavigationRoute route) => _activeRoute = route; /// - public NavigationRoute? CalculateRouteToNextPlace(double currentLat, double currentLon) + public NavigationRoute? CalculateRouteToNextPlace(double currentLat, double currentLon, bool activate = true) { - _activeRoute = _nextRouteToReturn; - return _activeRoute; + if (activate) _activeRoute = _nextRouteToReturn; + return _nextRouteToReturn; } /// diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs index 309d81c..27968b1 100644 --- a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorHostedRoutingTests.cs @@ -118,7 +118,7 @@ public async Task OpenChooser_RepeatedCatalogChange_RefreshesOnlyOnceAndRetainsD } [Fact] - public async Task DelayedHostedResponse_CurrentLocationChanges_DoesNotPublishToActiveDirectRoute() + public async Task DelayedHostedResponse_CurrentLocationChanges_PreservesPriorActiveRoute() { var routeResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var routeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -133,6 +133,8 @@ public async Task DelayedHostedResponse_CurrentLocationChanges_DoesNotPublishToA var (coordinator, navigation, _, callbacks) = CreateCoordinator(api.Object, dialogs); var location = new LocationData { Latitude = 37, Longitude = 23 }; callbacks.SetupGet(value => value.CurrentLocation).Returns(() => location); + var prior = await coordinator.CalculateRouteToCoordinatesAsync( + 37, 23, 37.005, 23.005, "Existing", "direct"); var pending = coordinator.CalculateRouteToCoordinatesAsync(37, 23, 37.01, 23.01, "Target", "foot"); await routeStarted.Task; @@ -140,11 +142,8 @@ public async Task DelayedHostedResponse_CurrentLocationChanges_DoesNotPublishToA 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(); + route.Should().BeNull(); + navigation.ActiveRoute.Should().BeSameAs(prior); } [Fact] @@ -473,6 +472,90 @@ public async Task FreshRoute_DismissedChooserReturnsNoRouteAndPreservesCurrentNa It.IsAny()), Times.Never); } + [Fact] + public async Task StaleChooserDismissal_DoesNotOverwriteOrCancelNewerRequest() + { + var firstChooser = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstPresented = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondRequestToken = CancellationToken.None; + var api = new Mock(MockBehavior.Strict); + api.Setup(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(Catalog(IdentityA, new HostedProviderMode("walk", "Walk"))); + api.Setup(client => client.GetCapabilityAsync(Guid.Empty, "walk", IdentityA, + It.IsAny())) + .ReturnsAsync(HostedRoutingCapability.Available(Guid.Empty, IdentityA, IdentityA, Attribution())); + api.Setup(client => client.GetRouteAsync(It.IsAny(), It.IsAny())) + .Callback((_, token) => secondRequestToken = token) + .ReturnsAsync(HostedRouteResponse.ValidForTest(Guid.Empty, IdentityA)); + var presentation = 0; + var dialogs = new Mock(MockBehavior.Strict); + dialogs.Setup(service => service.SelectAsync( + "Provider route mode (separate from the Segment Transport Profile)", + It.IsAny>(), "Direct")) + .Returns(() => + { + if (Interlocked.Increment(ref presentation) == 1) + { + firstPresented.SetResult(); + return firstChooser.Task; + } + return Task.FromResult("Walk"); + }); + var (coordinator, navigation, _, callbacks) = CreateCoordinator(api.Object, dialogs.Object); + callbacks.SetupGet(value => value.CurrentLocation) + .Returns(new LocationData { Latitude = 37, Longitude = 23 }); + + var stale = coordinator.CalculateRouteToCoordinatesAsync(37, 23, 37.01, 23.01, "A", "foot"); + await firstPresented.Task; + var current = await coordinator.CalculateRouteToCoordinatesAsync(37, 23, 37.01, 23.01, "B", "foot"); + var currentSelection = lastHostedRouting!.CurrentSelection; + firstChooser.SetResult(null); + + (await stale).Should().BeNull(); + api.Verify(client => client.GetCapabilityAsync(Guid.Empty, "walk", IdentityA, + It.IsAny()), Times.Once); + api.Verify(client => client.GetRouteAsync(It.IsAny(), + It.IsAny()), Times.Once); + current.Should().BeSameAs(navigation.ActiveRoute); + current!.IsDirectRoute.Should().BeFalse(); + lastHostedRouting.CurrentSelection.Should().BeSameAs(currentSelection); + currentSelection.Should().NotBeNull(); + currentSelection!.ProviderMode.Should().Be("walk"); + secondRequestToken.IsCancellationRequested.Should().BeFalse(); + } + + [Fact] + public async Task AuthorityChangeDuringDismissal_DoesNotReviveOldSelection() + { + var settings = new MockSettingsService(); + var api = new Mock(MockBehavior.Strict); + api.Setup(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(Catalog(IdentityA, new HostedProviderMode("walk", "Walk"))); + var dialogs = new Mock(MockBehavior.Strict); + dialogs.Setup(service => service.SelectAsync( + "Provider route mode (separate from the Segment Transport Profile)", + It.IsAny>(), "Direct")) + .Callback(() => + { + settings.ApiToken = "replacement-token"; + lastHostedRouting!.SelectDirect(100); + }) + .ReturnsAsync((string?)null); + var (coordinator, _, _, callbacks) = CreateCoordinator( + api.Object, dialogs.Object, suppliedSettings: settings); + callbacks.SetupGet(value => value.CurrentLocation) + .Returns(new LocationData { Latitude = 37, Longitude = 23 }); + lastHostedRouting!.SelectRetained(1, WalkingProfile, "walk", IdentityA); + + var route = await coordinator.CalculateRouteToCoordinatesAsync( + 37, 23, 37.01, 23.01, "Target", "foot"); + + route.Should().BeNull(); + lastHostedRouting.CurrentSelection.Should().BeNull(); + api.Verify(client => client.GetCapabilityAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task FreshRoute_ExplicitDirectReturnsDirectWithoutProviderContact() { @@ -535,8 +618,8 @@ public async Task AuthenticationChangesWhileChoosing_PreventCapabilityContact() var route = await coordinator.CalculateRouteToCoordinatesAsync( 37, 23, 37.01, 23.01, "Target", "foot"); - route.Should().BeSameAs(navigation.ActiveRoute); - route.IsDirectRoute.Should().BeTrue(); + route.Should().BeNull(); + navigation.ActiveRoute.Should().BeNull(); api.Verify(client => client.GetCapabilityAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorTripChooserTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorTripChooserTests.cs new file mode 100644 index 0000000..4860181 --- /dev/null +++ b/tests/WayfarerMobile.Tests/Unit/ViewModels/NavigationCoordinatorTripChooserTests.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.Logging.Abstractions; +using WayfarerMobile.Data.Repositories; +using WayfarerMobile.Data.Services; +using WayfarerMobile.Services; +using WayfarerMobile.Tests.Infrastructure.Mocks; +using WayfarerMobile.ViewModels; + +namespace WayfarerMobile.Tests.Unit.ViewModels; + +[Collection("SQLite")] +public sealed class NavigationCoordinatorTripChooserTests : IAsyncLifetime +{ + private const string Identity = "v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + private readonly List connections = []; + private readonly List databasePaths = []; + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + foreach (var connection in connections) await connection.CloseAsync(); + foreach (var path in databasePaths) + if (File.Exists(path)) File.Delete(path); + } + + [Fact] + public async Task TripPlaceDismissal_PreservesActiveRouteAndNavigationState() + { + var scenario = CreateScenario(null); + var prior = await scenario.Navigation.CalculateRouteToCoordinatesAsync( + 37, 23, 37.001, 23.001, "Existing"); + scenario.Coordinator.IsNavigating = true; + + await scenario.Coordinator.StartNavigationToPlaceAsync(scenario.Destination.Id.ToString()); + + scenario.Navigation.ActiveRoute.Should().BeSameAs(prior); + scenario.Coordinator.IsNavigating.Should().BeTrue(); + scenario.Callbacks.Verify(value => value.ShowNavigationRoute(It.IsAny()), Times.Never); + VerifyNoProviderRequest(scenario.Api); + } + + [Fact] + public async Task NextPlaceDismissal_PreservesActiveRouteAndNavigationState() + { + var scenario = CreateScenario(null); + var prior = await scenario.Navigation.CalculateRouteToCoordinatesAsync( + 37, 23, 37.001, 23.001, "Existing"); + scenario.Coordinator.IsNavigating = true; + + await scenario.Coordinator.StartNavigationToNextAsync(); + + scenario.Navigation.ActiveRoute.Should().BeSameAs(prior); + scenario.Coordinator.IsNavigating.Should().BeTrue(); + scenario.Callbacks.Verify(value => value.ShowNavigationRoute(It.IsAny()), Times.Never); + VerifyNoProviderRequest(scenario.Api); + } + + [Fact] + public async Task TripPlaceExplicitDirect_ActivatesDirectWithoutProviderRequest() + { + var scenario = CreateScenario("Direct"); + + await scenario.Coordinator.StartNavigationToPlaceAsync(scenario.Destination.Id.ToString()); + + scenario.Navigation.ActiveRoute.Should().NotBeNull(); + scenario.Navigation.ActiveRoute!.IsDirectRoute.Should().BeTrue(); + scenario.Coordinator.IsNavigating.Should().BeTrue(); + scenario.Callbacks.Verify(value => value.ShowNavigationRoute(scenario.Navigation.ActiveRoute), Times.Once); + VerifyNoProviderRequest(scenario.Api); + } + + private Scenario CreateScenario(string? chooserResult) + { + var origin = new TripPlace + { + Id = Guid.NewGuid(), Name = "Origin", Latitude = 37, Longitude = 23, SortOrder = 0 + }; + var destination = new TripPlace + { + Id = Guid.NewGuid(), Name = "Destination", Latitude = 38, Longitude = 24, SortOrder = 1 + }; + var trip = new TripDetails + { + Id = Guid.NewGuid(), Name = "Trip", + Regions = [new TripRegion { Id = Guid.NewGuid(), Name = "Region", Places = [origin, destination] }] + }; + var state = new MockTripStateManager(); + state.SetLoadedTrip(trip); + var navigation = new TripNavigationService( + NullLogger.Instance, + Mock.Of(), + new NavigationRouteBuilder(NullLogger.Instance), state); + navigation.LoadTrip(trip).Should().BeTrue(); + var api = new Mock(MockBehavior.Strict); + api.Setup(client => client.DiscoverAsync(It.IsAny())) + .ReturnsAsync(new HostedRoutingCatalog(Identity, "available", [], "geoapify", + [new HostedProviderMode("walk", "Walk")])); + var dialogs = new Mock(MockBehavior.Strict); + dialogs.Setup(service => service.SelectAsync( + "Provider route mode (separate from the Segment Transport Profile)", + It.IsAny>(), "Direct")) + .ReturnsAsync(chooserResult); + var coordinator = new NavigationCoordinatorViewModel( + navigation, new NavigationHudViewModel(), Mock.Of(), + new HostedRoutingService(api.Object, NullLogger.Instance), + CreateRetainedRoutingService(), new MockSettingsService(), dialogs.Object, state, + NullLogger.Instance); + var callbacks = new Mock(); + callbacks.SetupGet(value => value.CurrentLocation) + .Returns(new LocationData { Latitude = origin.Latitude, Longitude = origin.Longitude }); + coordinator.SetCallbacks(callbacks.Object); + return new(coordinator, navigation, callbacks, api, destination); + } + + private RetainedWayfarerRoutingService CreateRetainedRoutingService() + { + var path = Path.Combine(Path.GetTempPath(), $"wayfarer-navigation-trip-{Guid.NewGuid():N}.db3"); + var connection = new SQLite.SQLiteAsyncConnection(path); + connections.Add(connection); + databasePaths.Add(path); + RetainedWayfarerRouteMigration.ApplyAsync(connection, CancellationToken.None).GetAwaiter().GetResult(); + return new(new RetainedWayfarerRouteRepository(connection), + NullLogger.Instance); + } + + private static void VerifyNoProviderRequest(Mock api) + { + api.Verify(client => client.GetCapabilityAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + api.Verify(client => client.GetRouteAsync(It.IsAny(), + It.IsAny()), Times.Never); + } + + private sealed record Scenario(NavigationCoordinatorViewModel Coordinator, + TripNavigationService Navigation, Mock Callbacks, + Mock Api, TripPlace Destination); +} From 1460112438293b5314b5c73ff2daab4345ea1571 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Thu, 3 Sep 2026 21:08:44 +0300 Subject: [PATCH 5/5] fix(navigation): release owned chooser cancellation --- .../ViewModels/NavigationCoordinatorViewModel.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs index 5148091..57ec30e 100644 --- a/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs +++ b/src/WayfarerMobile/ViewModels/NavigationCoordinatorViewModel.cs @@ -418,6 +418,7 @@ await _retainedRouting.SaveAsync(result.Candidate, partition, DateTimeOffset.Utc "Provider route mode (separate from the Segment Transport Profile)", options, "Direct"); if (selected == null) { + ReleaseDismissedInvocation(context, partition, cancellation); return null; } if (selected == "Direct") @@ -564,6 +565,15 @@ private bool IsInvocationCurrent(HostedRouteRequestContext context, Guid partiti CancellationTokenSource cancellation) => ReferenceEquals(_hostedRoutingCancellation, cancellation) && !cancellation.IsCancellationRequested && IsRequestCurrent(context, partition); + private void ReleaseDismissedInvocation(HostedRouteRequestContext context, Guid partition, + CancellationTokenSource cancellation) + { + if (!IsInvocationCurrent(context, partition, cancellation)) return; + if (ReferenceEquals(Interlocked.CompareExchange( + ref _hostedRoutingCancellation, null, cancellation), cancellation)) + cancellation.Dispose(); + } + private void CancelHostedRouting(bool incrementGeneration = true) { if (incrementGeneration) _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration));