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.Core/Interfaces/ITripNavigationService.cs b/src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs
index 0c212d6..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.
@@ -95,12 +96,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.
@@ -108,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.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..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)
@@ -272,6 +273,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 +366,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;
@@ -388,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/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..031aded 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);
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);
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..61f2129 100644
--- a/src/WayfarerMobile/Services/HostedRoutingService.cs
+++ b/src/WayfarerMobile/Services/HostedRoutingService.cs
@@ -27,55 +27,51 @@ 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,
- 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,
- 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,34 +100,41 @@ 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;
}
}
- private bool Begin(HostedRouteRequestContext context)
+ /// Commits a validated candidate only while its invocation still owns service state.
+ public bool TrySelectCandidate(HostedRouteCandidate candidate, HostedRouteSelection? expectedSelection)
{
lock (stateLock)
{
- if (context.Generation < activeGeneration) return false;
- activeGeneration = context.Generation;
- currentSelection = null;
- IsLoading = true;
+ 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 SelectCurrent(long generation, Guid profileId, string authorityIdentity)
+ private bool IsCurrentGeneration(long generation)
+ {
+ lock (stateLock) return activeGeneration == generation;
+ }
+
+ private bool Begin(HostedRouteRequestContext context)
{
lock (stateLock)
{
- if (activeGeneration != generation) return false;
- currentSelection = new(generation, profileId, authorityIdentity);
+ if (context.Generation < activeGeneration) return false;
+ activeGeneration = context.Generation;
+ IsLoading = true;
return true;
}
}
@@ -140,21 +143,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 +178,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/Services/TripNavigationService.cs b/src/WayfarerMobile/Services/TripNavigationService.cs
index fd4c465..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;
}
@@ -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,27 +210,30 @@ 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.
///
/// 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)
@@ -240,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/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/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/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 6abbd12..57ec30e 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,
@@ -313,23 +319,27 @@ 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.
- public async Task CalculateHostedRouteToCoordinatesAsync(
+ public async Task CalculateHostedRouteToCoordinatesAsync(
double fromLat, double fromLon, double toLat, double toLon, string destinationName,
string profile, string targetAssociation, Func currentTarget)
{
var direct = await _tripNavigationService.CalculateRouteToCoordinatesAsync(
- fromLat, fromLon, toLat, toLon, destinationName, profile);
- return await TryHostedAsync(direct, fromLat, fromLon, toLat, toLon, destinationName,
+ 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)
{
@@ -349,39 +359,44 @@ 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 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)
+ long generation, Guid partition, HostedRouteProvenance? retainedFallback,
+ 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
{
@@ -395,31 +410,45 @@ 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.DisplayName} — {item.ModeKey} ({item.TransportProfileId:D})").ToArray();
- var selected = await _dialogs.SelectAsync("Wayfarer routing profile", options, "Direct");
- var index = selected == null ? -1 : Array.IndexOf(options, selected);
- if (index < 0)
+ item.Label).ToArray();
+ var selected = await _dialogs.SelectAsync(
+ "Provider route mode (separate from the Segment Transport Profile)", options, "Direct");
+ if (selected == null)
+ {
+ ReleaseDismissedInvocation(context, partition, cancellation);
+ return null;
+ }
+ if (selected == "Direct")
{
if (retainedFallback != null)
RestoreRetainedSelection(context, partition, retainedFallback);
- else _hostedRouting.SelectDirect(Interlocked.Increment(ref _hostedRoutingGeneration));
- return null;
+ else if (IsInvocationCurrent(context, partition, cancellation))
+ _hostedRouting.SelectDirect(generation);
+ return new(HostedRoutingOutcome.Cancelled);
}
- if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation) return null;
- var choiceContext = context with { ExpectedCatalogIdentity = result.DiscoveryCatalogIdentity };
+ var index = Array.IndexOf(options, selected);
+ if (index < 0) return new(HostedRoutingOutcome.Unavailable);
+ if (_hostedRoutingGeneration != generation || _hostedRequest?.Generation != generation
+ || !IsRequestCurrent(context, partition)) return new(HostedRoutingOutcome.Unavailable);
+ var choiceContext = context with
+ {
+ ExpectedCatalogIdentity = result.DiscoveryCatalogIdentity,
+ ExpectedProvider = result.Provider
+ };
_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));
- return null;
+ else if (IsInvocationCurrent(context, partition, cancellation)) _hostedRouting.SelectDirect(generation);
+ return new(HostedRoutingOutcome.Unavailable);
}
private async Task ResolveRetainedChoiceAsync(NavigationRoute target,
@@ -435,7 +464,7 @@ private async Task ResolveRetainedChoiceAsync(NavigationR
{
if (HostedRoutePublication.TryPublishRetained(retained, target))
_hostedRouting.SelectRetained(context.Generation, provenance.TransportProfileId,
- provenance.SelectedProfileAuthorityIdentity);
+ provenance.ProviderMode ?? string.Empty, provenance.SelectedProfileAuthorityIdentity);
return new(true, null);
}
if (choice != "Refresh with Wayfarer")
@@ -445,7 +474,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 +493,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,
@@ -480,12 +509,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;
@@ -509,7 +539,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
@@ -531,6 +561,19 @@ 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 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));
diff --git a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockTripNavigationService.cs
index 4c76cab..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;
}
///
@@ -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,15 +144,18 @@ public Task CalculateRouteToCoordinatesAsync(double currentLat,
EstimatedDuration = TimeSpan.FromSeconds(600),
IsDirectRoute = true
};
- _activeRoute = route;
+ if (activate) _activeRoute = route;
return Task.FromResult(route);
}
///
- public NavigationRoute? CalculateRouteToNextPlace(double currentLat, double currentLon)
+ public void ActivateRoute(NavigationRoute route) => _activeRoute = route;
+
+ ///
+ 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/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 5588ef1..eaf0caf 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,20 +63,25 @@ 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);
+ capability.SelectedProfileAuthorityIdentity!, "walk"), default);
route.Succeeded.Should().BeTrue();
route.Provider.Should().Be("geoapify");
route.ProviderConfigurationId.Should().Be(Guid.Parse("22222222-2222-2222-2222-222222222222"));
route.MappingIdentity.Should().Be("mapping");
route.StorageMode.Should().Be("persistent");
+ route.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");
}
@@ -143,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();
@@ -170,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 9149f5b..56e14e8 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);
}
@@ -88,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,
@@ -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/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 1df1f4a..27968b1 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,48 +31,45 @@ public async Task DisposeAsync()
}
[Fact]
- public async Task OpenChooser_CatalogChanges_SubmitsDisplayedIdentityThenRefreshesBeforeReselection()
+ public async Task OpenChooser_CatalogChangesThenDismissalReturnsNoRoute()
{
- 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 });
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().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,16 +109,16 @@ 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);
}
[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);
@@ -132,10 +129,12 @@ 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);
+ 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;
@@ -143,18 +142,15 @@ 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]
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 +159,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 +219,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 +234,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 +243,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 +311,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 });
@@ -358,7 +355,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,
@@ -378,11 +375,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,
@@ -408,18 +407,220 @@ 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;
+ }
+
+ [Fact]
+ public async Task FreshRoute_DismissedChooserReturnsNoRouteAndPreservesCurrentNavigation()
+ {
+ 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 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 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()
+ {
+ 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();
+ 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().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);
+}