Ferrostar

Ferrostar is a modern SDK for building turn-by-turn navigation applications.

Ferrostar is...

  • Modern - The core is written in Rust, making it easy to contribute to, maintain, and port to new architectures. Platform specific libraries for iOS and Android leverage the best features of Swift and Kotlin.
  • Batteries included - Navigation UI should be usable out of the box for the most common use cases in native iOS and Android apps with minimal reconfiguration needed. Don't like our UI? Most components are reusable and composable thanks to SwiftUI and Jetpack Compose.
  • Extensible - At every layer, you have flexibility to extend or replace functionality without needing to wait for a patch. Want to bring your own offline routing? Can do. Want to use your own detection logic to see if the user is off the route? Not a problem. Taken together with the batteries included approach, Ferrostar's aim is to make simple things simple, and complex things possible.
  • Vendor-neutral - As a corollary to its extensibility, Ferrostar is vendor-neutral, and welcomes PRs to add support for additional vendors. We do not collect telemetry for any vendor (though developers may of course add their own when needed).
  • Open-source - Ferrostar is open-source. No funky strings; just BSD.

Ferrostar is not...

  • Aiming for compatibility with ancient SDKs / API levels, except where it’s easy; this is a rare chance for a fresh start.
  • A routing engine; there are many good vendors that provide hosted APIs and offline route generation, as well as a rich ecosystem of FOSS software if you're looking to host your own for a smaller deployment.
  • Optimized for a "free roam" experience without any specific route (though it should totally be possible to plug Ferrostar into such an experience!).
  • Building UI components for addresses search (look at vendor SDKs that can help with this) or complex trip planning.

Terminology and conventions

In this guide, we will use the following terms as specified. Cases where a more narrow interpretation is needed should be obvious.

  • Interface - When used in a context that’s talking about code, we use this term to mean a method or type signature. For example, we will use the term interface to refer to Kotlin interfaces, Swift protocols, and Rust traits. We also use the term to refer to a type’s public interface as in the available properties to an end user such as yourself.
  • Kotlin - We’ll be quite loose when talking about “Kotlin.” It would be too cumbersome to write out something like Kotlin/Java or “your favorite JVM language.” When we speak of Kotlin, we usually mean any JVM language, except when referring to specific Kotlin features. While all example code is in Kotlin, things should work equally well in Java.
  • Platform - When we refer to “platform libraries”, the “platform layer”, similar, we are referring to code written for/targeting the end deployment platform. Not the Rust core but platform-specific code like Swift or Kotlin.

How to use this guide

This guide is broken up into several sections. The tutorial is designed to get you started quickly. Read this first (at least the chapter for your platform). Then you can pretty much skip around at will.

If you want to go deeper and customize the user experience, check out the chapters on customization. If you can’t find what you want, feel free to open an issue on GitHub.

The architecture section documents the design of Ferrostar and its various components. If you want to add support for a new routing API, post-process location updates, or contribute to the development of Ferrostar, this is where the authoritative docs live. (If you want to contribute, be sure to check out CONTRIBUTING.md!)

Platform Support Targets

We’re building a navigation SDK for the future, but we acknowledge that your users live in the present. Our general policy is to expect developers to have up-to-date build tools, but support older devices where possible without compromising the maintainability and future-readiness of the project.

Rust

The core team develops using the latest stable Rust release. At the moment, our MSRV is dictated by downstream projects. Until the project is stable, it is unlikely that we will have a more formal MSRV policy, but we do document the current MSRV in the root Cargo.toml and verify it via CI.

Swift

The core requires Swift 5.9+. We are iterating on a more ergonomic wrapper for MapLibre Native on iOS, and this leverages macros, which drive this requirement.

iOS

iOS support starts at version 15. Our general policy will be to support the current and at least the previous major version, extending to two major versions if possible. At the time of this writing, the “current-1” policy covers 96% of iOS devices. This is a pretty standard adoption rate for iOS.

Android

Android developers should always build using the latest publicly available Android Studio version.

However, Android end users are much slower to get new OS major versions for many reasons. We currently support API level 25 and higher (with some caveats). At the time of this writing, it covers 96% of Android users. We will use publicly available data on API levels and developer feedback to set API level requirements going forward.

Android API Level caveats

API levels lower than 26 do not include support for several Java 8 APIs. Crucially, the Instant API, which is essential for the library, is not present. If you cannot raise your minimum SDK to 26 or higher, you may need to enable Java 8+ API desugaring support.

Additionally, Android before API 30 has to fall back on older ICU APIs.

We recommend supporting the newest API version possible for your user base, as Google officially drops support for older releases after just a few years.

Getting Started on iOS

This section of the guide covers how to integrate Ferrostar into an iOS app. We'll cover the "batteries included" approach, but flag areas for customization and overrides along the way.

Add the Swift package dependency

If you’re not familiar with adding Swift Package dependencies to apps, Apple has some helpful documentation. You can search for the repository via its URL: https://github.com/stadiamaps/ferrostar.

Configure location services

In order to access the user’s location, you’ll need to set a key in your Info.plist or similar file. This is something you can set in Xcode by going to your project, selecting the target, and going to the Info tab.

You need to add row for “Privacy - Location When In Use Usage Description” (right-click any of the existing rows and click “Add row”) or, if you’re using raw keys, NSLocationWhenInUseUsageDescription. Fill in a description of why your app needs access to their location. Presumably something related to navigation ;)

Location providers

You'll need to configure a provider to get location updates. We bundle a few implementations to get you started, or you can create your own. The broad steps are the same regardless of which provider you use: create an instance of the class, store it in an instance variable where it makes sense, and (if simulating a route) set the location manually or enter a simulated route.

The API is similar to the iOS location APIs you may already know, and you can start or stop updates at will.

You should store your location provider in a place that is persistent for as long as you need it. Most often this makes sense as a private @StateObject if you’re using SwiftUI.

CoreLocationProvider

The CoreLocationProvider provides a ready-to-go wrapper around a CLLocationManager for getting location updates from GNSS. It will automatically request permissions for you as part of initialization.

@StateObject private var locationProvider = CoreLocationProvider(activityType: .otherNavigation)

SimulatedLocationProvider

The SimulatedLocationProvider allows for simulating location within Ferrostar without needing GPX files or complicated environment setup. This is great for testing and development without stepping outside.

First, instantiate the class. This is usually saved as an instance variable.

@StateObject private var locationProvider = SimulatedLocationProvider(location: initialLocation)

You can set a new location using the lastLocation property at any time. Optionally, once you have a route, simulate the replay of the route. You can set a warpFactor to play it back faster.

locationProvider.warpFactor = 2
locationProvider.setSimulatedRoute(route)

Configure the FerrostarCore instance

Next, you’ll want to create a FerrostarCore instance. This is your interface into Ferrostar. You’ll also want to keep this around as a persistent property and use it in SwiftUI using a private @StateObject or @ObservedObject in most situations.

FerrostarCore provides several initializers, including convenient initializers for Valhalla routing backends and the ability to provide your own custom routing (ex: for local/offline use).

Route Providers

You’ll need to decide on a route provider when you set up your FerrostarCore instance. For limited testing, FOSSGIS maintains a public server with the URL https://valhalla1.openstreetmap.de/route. For production use, you’ll need another solution like a commercial vendor or self-hosting.

Getting a route

Before getting routes, you’ll need the user’s current location. You can get this from the location provider (which is part of why you’ll want to hang on to it). Next, you’ll need a set of waypoints to visit. Finally, you can use the asynchronous getRoutes method on FerrostarCore. Here’s an example:

Task {
    do {
        routes = try await ferrostarCore.getRoutes(initialLocation: userLocation, waypoints: [Waypoint(coordinate: GeographicCoordinate(cl: loc.location), kind: .break)])

        errorMessage = nil
        
        // TODO: Let the user select a route, or pick one programmatically
    } catch {
        // Communicate the error to the user.
        errorMessage = "Error: \(error)"
    }
}

Starting a navigation session

Once you or the user has selected a route, it’s time to start navigating!

try ferrostarCore.startNavigation(route: route, config: SwiftNavigationControllerConfig(stepAdvance: .relativeLineStringDistance(minimumHorizontalAccuracy: 32, automaticAdvanceDistance: 10), routeDeviationTracking: .staticThreshold(minimumHorizontalAccuracy: 25, maxAcceptableDeviation: 25)))

From this point, FerrostarCore automatically starts the LocationProvider updates, and will use Combine, the SwiftUI observation framework, to publish state changes.

Using the NavigationMapView

So now navigation is “started” but what does that mean? Let’s turn these state updates into a familiar map-centric experience!

We’ll use the DynamicallyOrientingNavigationView together with a map style, the state from the core, and many more configurable properties See the class documentation for details on each parameter, but you have the ability to customize most of the camera behavior.

// NOTE: You can get a free Stadia Maps API key at https://client.stadiamaps.com
let styleURL = URL(string: "https://tiles.stadiamaps.com/styles/outdoors.json?api_key=\(stadiaMapsAPIKey)")!
DynamicallyOrientingNavigationView(
    styleURL: styleURL,
    navigationState: state,
    camera: $camera,
    snappedZoom: .constant(18),
    useSnappedCamera: .constant(true))

Demo app

We've put together a minimal demo app with an example integration.

Going deeper

This covers the basic “batteries included” configuration which works for simple apps. But there’s a lot of room for customization! Skip on over to the customization chapters that interest you.

Getting Started on Android

This section of the guide covers how to integrate Ferrostar into an Android app. We'll cover the "batteries included" approach, but flag areas for customization and overrides along the way.

Note that while this section is WIP, we have a well-documented demo app. The TODOs will get filled in eventually, but the demo app is a good reference for now.

Minimum requirements

See the platform support targets document for details on supported Android versions.

Add dependencies

Ferrostar releases are hosted on GitHub Packages. You’ll need to authenticate first in order to access them. GitHub has a guide on setting this up.

Maven repository setup

Once you’ve configured GitHub credentials as project properties or environment variables, Add the repository to your build script.

If you are using settings.gradle for your dependency resolution management, you’ll end up with something like along these lines:

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        maven {
            url = 'https://maven.pkg.github.com/stadiamaps/ferrostar'
            credentials {
                username = settings.ext.find('gpr.user') ?: System.getenv('GITHUB_ACTOR')
                password = settings.ext.find('gpr.token') ?: System.getenv('GITHUB_TOKEN')
            }
        }
        
        // For the MapLibre compose integration
        maven {
            url = 'https://maven.pkg.github.com/Rallista/maplibre-compose-playground'
            credentials {
                username = settings.ext.find('gpr.user') ?: System.getenv('GITHUB_ACTOR')
                password = settings.ext.find('gpr.token') ?: System.getenv('GITHUB_TOKEN')
            }
        }

        google()
        mavenCentral()
    }
}

And if you’re doing this directly in build.gradle, things look slightly different:

repositories {
    google()
    mavenCentral()

    maven {
        url = uri("https://maven.pkg.github.com/stadiamaps/ferrostar")
        credentials {
            username = project.findProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR")
            password = project.findProperty("gpr.token") ?: System.getenv("GITHUB_TOKEN")
        }
    }
    
    // For the MapLibre compose integration
    maven {
        url = uri("https://maven.pkg.github.com/Rallista/maplibre-compose-playground")
        credentials {
            username = settings.ext.find("gpr.user") ?: System.getenv("USERNAME")
            password = settings.ext.find("gpr.token") ?: System.getenv("TOKEN")
        }
    }
}

Add dependencies

build.gradle with explicit version strings

If you’re using the classic build.gradle with implementation strings using hard-coded versions, here’s how to set things up. Replace X.Y.Z with an appropriate version.

dependencies {
    // Elided: androidx dependencies: ktx and compose standard deps

    // Ferrostar
    def ferrostarVersion = 'X.Y.Z'
    implementation "com.stadiamaps.ferrostar:core:${ferrostarVersion}"
    implementation "com.stadiamaps.ferrostar:maplibreui:${ferrostarVersion}"

    // okhttp3
    implementation platform("com.squareup.okhttp3:okhttp-bom:4.11.0")
    implementation 'com.squareup.okhttp3:okhttp'
}

libs.versions.toml “modern style”

If you’re using the newer libs.versions.toml approach, add the versions like so:

[versions]
ferrostar = "X.Y.X"
okhttp3 = "4.11.0"

[libraries]
ferrostar-core = { group = "com.stadiamaps.ferrostar", name = "core", version.ref = "ferrostar" }
ferrostar-maplibreui = { group = "com.stadiamaps.ferrostar", name = "maplibreui", version.ref = "ferrostar" }
okhttp3 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp3" }

Then reference it in your build.gradle:

dependencies {
    // Elided: androidx dependencies: ktx and compose standard deps

    // Ferrostar
    implementation libs.ferrostar.core
    implementation libs.ferrostar.maplibreui

    // okhttp3
    implementation libs.okhttp3
}

Configure location services

Declaring permissions used

Your app will need access to the user’s location. First, you’ll need the requisite permissions in your Android manifest:

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

You’ll then need to request permission from the user to access their precise location.

Requesting location access

The “best” way to do this tends to change over time and varies with your app structure. If you’re using Jetpack Compose, you’ll want the rememberLauncherForActivityResult API. If you’re just using plain activities, the registerForActivityResult has what you need.

In either case, you’ll want to review Google’s documentation.

Ensuring updates when the app loses focus

Note that Ferrostar does not require “background” location access! This may be confusing if you’re new to mobile development. On Android, we can use something called a foreground service which lets us keep getting location updates even when the app isn’t front and center.

TODO: Tutorial on this

Location providers

You'll need to configure a provider to get location updates. We bundle a few implementations to get you started, or you can create your own. The broad steps are the same regardless of which provider you use: create an instance of the class, store it in an instance variable where it makes sense, and (if simulating a route) set the location manually or enter a simulated route.

Similar to the Android location APIs you may already know, you can add or remove listeners which will receive updates.

SimulatedLocationProvider

The SimulatedLocationProvider allows for simulating location within Ferrostar without needing GPX files or complicated environment setup. This is great for testing and development without stepping outside.

First, instantiate the class. This will typically be saved as an instance variable.

private val locationProvider = SimulatedLocationProvider()

Later, most likely somewhere in your activity creation code or similar, set a location to your desired simulation start point.

locationProvider.lastLocation = initialSimulatedLocation

Once you have a route, you can simulate the replay of the route. This is technically optional (you can just set lastLocation yourself too), but playing back a route saves you the effort. You can set a warpFactor to play it back faster.

locationProvider.warpFactor = 2u
locationProvider.setSimulatedRoute(route)

You don’t need to do anything else after setting a simulated route; FerrostarCore will automatically add itself as a listener, which will trigger updates.

TODO: Google Play Services-backed provider

Configure an HTTP client

Before we configure the Ferrostar core, we need to set up an HTTP client. This is typically stored as an instance variable in one of your classes (ex: activity). We use the popular OkHttp library for this. Here we’ve set up a client with a global timeout of 15 seconds. Refer to the OkHttp documentation for further details on configuration.

private val httpClient = OkHttpClient.Builder()
    .callTimeout(Duration.ofSeconds(15))
    .build()

Configure the FerrostarCore instance

We now have all the pieces we need to set up a FerrostarCore instance! The FerrostarCore instance should live for at least the duration of a navigation session. Bringing it all together, a typical init looks something like this:

private val core =
      FerrostarCore(
          valhallaEndpointURL = URL("https://api.stadiamaps.com/route/v1?api_key=YOUR-API-KEY"),
          profile = "bicycle",
          httpClient = httpClient,
          locationProvider = locationProvider,
      )

FerrostarCore exposes a number of convenience constructors for common cases, such as using a Valhalla Route Provider.

FerrostarCore automatically subscribes to location updates from the LocationProvider.

Set up voice guidance

Ferrostar is able to process spoken instructions generated from some routing engines. The com.stadiamaps.ferrostar.core.SpokenInstructionObserver interface specifies haw to create your own observer. A reference implementation is provided in the AndroidTtsObserver class, which uses the text-to-speech engine built into Android. PRs welcome for other popular services (ex: Amazon Polly; note that some APIs also provide SSML instructions which work great with this!).

TODO documentation:

  • Android Manifest
  • Set the language
  • Additional config (you have full control)

Getting a route

Getting a route is easy! All you need is the start location (from the location provider) and a list of at least one waypoint to visit.

val routes =
    core.getRoutes(
        userLocation,
        listOf(
            Waypoint(
                coordinate = GeographicCoordinate(37.807587, -122.428411),
                kind = WaypointKind.BREAK),
        ))

Note that this is a suspend function, so you’ll need to use it within a coroutine scope. You probably want something like launch(Dispatchers.IO) { .. } for most cases to ensure it’s running on the correct dispatcher. You may select a different dispatcher if you are doing offline route calculation.

Starting a navigation session

Once you have a route (ex: by grabbing the first one from the list or by presenting the user with a selection screen), you’re ready to start a navigation session!

When you start a navigation session, the core returns a view model which will automatically be updated with state updates.

You can save “rememberable” state somewhere near the top of your composable block like so:

var navigationViewModel by remember { mutableStateOf<NavigationViewModel?>(null) }

And then use it to store the result of your startNavigation invocation:

navigationViewModel =
    core.startNavigation(
        route = route,
        config =
        NavigationControllerConfig(
            StepAdvanceMode.RelativeLineStringDistance(
                minimumHorizontalAccuracy = 25U, automaticAdvanceDistance = 10U),
            RouteDeviationTracking.StaticThreshold(25U, 10.0)),
    )

Finally, If you’re simulating route progress (ex: in the emulator) with a SimulatedLocationProvider), set the route:

locationProvider.setSimulatedRoute(route)

Using the NavigationMapView

We’re finally ready to turn that view model into a beautiful navigation map! It’s really as simple as creating a NavigationMapView with the view model. Here’s an example:

 val viewModel = navigationViewModel
 if (viewModel != null) {
     // Demo tiles illustrate a basic integration without any API key required,
     // but you can replace the styleURL with any valid MapLibre style URL.
     // See https://stadiamaps.github.io/ferrostar/vendors.html for some vendors.
     NavigationMapView(
         styleUrl =
         "https://tiles.stadiamaps.com/styles/outdoors.json?api_key=$stadiaApiKey",
         viewModel = viewModel) { uiState ->
         // You can add your own overlays here!
         // See https://github.com/Rallista/maplibre-compose-playground
     }
 } else {
     // Loading indicator
     Column(
         verticalArrangement = Arrangement.Center,
         horizontalAlignment = Alignment.CenterHorizontally) {
         Text(text = "Calculating route...")
         CircularProgressIndicator(modifier = Modifier.width(64.dp))
     }
 }

Demo app

We've put together a minimal demo app with an example integration.

Going deeper

This covers the basic “batteries included” configuration which works for simple apps. But there’s a lot of room for customization! Skip on over to the customization chapters that interest you.

Rust

Not using a platform listed / building an embedded application? You're in the right spot.

Documentation on docs.rs. Start with the navigation controller.

TODO: Tutorial.

General Customizations

Customizing FerrostarCore behaviors

FerrostarCore sits at the heart of your interactions with Ferrostar. It provides an idiomatic way to interact with the core from native code at the platform layer.

Ferrostar NavigationControllerConfig options

These customizations apply to the NavigationController. They are surfaced at the platform layer as an argument to the startNavigation method of FerrostarCore.

Step advance

The step advance mode controls when navigation advances to the next step in the route. See the StepAdvanceMode for details (either in the rustdocs or in the equivalent platform library docs; the API is bridged idiomatically to Swift and Kotlin).

Ferrostar currently includes several simplistic methods, but more implementations are welcome!

You can use the manual step advance mode to disable automatic progress, and trigger advance on your own using the advanceToNextStep method on FerrostarCore at the platform layer.

Route deviation tracking

Ferrostar recognizes that there is no one-size-fits-all solution for determining when a user has deviated from the intended route. The core provides several configurable detection strategies, but you can also bring your own. (And yes, you can write your custom deviation detection code in your native mobile codebase.)

See the RouteDeviationTracking rustdocs for the list of available strategies and parameters. (The Rust API is bridged idiomatically to Swift and Kotlin.)

Configuring a RouteDeviationHandler

By default, Ferrostar will fetch new routes when the user goes off course. You can override this behavior with hooks on FerrostarCore.

On iOS, all customization happens via the delegate. Implement the core:correctiveActionForDeviation:remainingWaypoints method on your delegate and you’re all set. On Android, set the deviationHandler property.

Refer to the demo apps for custom implementation examples. Note that you can disable the default behavior of attempting to reroute by setting an empty implementation (not setting the property to nil/null!).

Alternate route handling

FerrostarCore may occasionally load alternative routes. At this point, this is only used for recalculating when the user deviates from the route, but it may be used for additional behaviors in the future, such as speculative route checking based on current traffic (for supported vendors).

By default, Ferrostar will “accept” the new route and reroute the active navigation session whenever a new route arrives while the user is off course.

On iOS, you can configure this behavior by adding a delegate to the core and implementing the core:loadedAlternateRoutes method. On Android, you can configure this behavior by providing an AlternateRouteProcessor implementation to FerrostarCore.

Refer to the demo apps examples of custom implementations.

Note that you can disable the default behavior of attempting to reroute by setting an empty implementation (not setting the property to nil/ null!).

Customizing prompt timings

While most APIs don’t offer customizable timing for banners and spoken prompts, you can edit the standardized Route responses directly! A functional map operation drilling down to the instructions is an elegant way to change trigger distances.

SwiftUI

The tutorial get you set up with defaults using a “batteries included” UI, but realistically this doesn’t work for every use case. This page walks you through the ways to customize the SwiftUI frontend to your liking.

Note that this section is very much WIP.

Customizing the map

Ferrostar includes a map view based on MapLibre Native. This is configurable with a number of constructor parameters. If the existing customizations don’t work for you, first we’d love to hear why via an issue on GitHub! In the case that you want complete control though, the map view itself is actually not that complex.

TODO: Write-up on the views as these are still in flux. See the demo app for a high-level example and look at the views it uses for now.

Style

We allow you to pass a style URL to any of the map view constructors. You can vary this dynamically as your app theme changes (ex: in dark mode).

TODO: Passing a view builder to add layers to the map (WIP)

Camera

TODO: Ability to override the built-in camera behavior (probably define a protocol for this).

Customizing the instruction banners

Ferrostar includes a number of views related to instruction banners. These are composed together to provide sensible defaults, but you can customize a number of things.

Distance formatting

By default, banners and other UI elements involving distance will be formatted using an MKDistanceFormatter.

This should “just work” for most cases as it is aware of the device’s locale. However, you can customize the formatting by passing in any arbitrary Formatter. This can be your own specially configured MKDistanceFormatter or a custom subclass which formats things to your liking.

The InstructionsView is shipped with Ferrostar is the default banner view. It uses the public domain directions iconography from Mapbox in a standard layout.

This view is an excellent example of composability, and is comprised of several subviews. The units of the InstructionsView are controlled using the formatter settings you passed to the NavigationMapView (if you’re using it). If you’re not using the NavigationMapView, you can pass a formatter directly.

You can also build your own custom banners using the provided components, or start from scratch.

TODO: Expose a view builder argument so that users can easily swap for their own view.

Jetpack Compose

The tutorial get you set up with defaults using a “batteries included” UI, but realistically this doesn’t work for every use case. This page walks you through the ways to customize the Compose UI to your liking.

Note that this section is very much WIP.

Customizing the map

Ferrostar includes a NavigationMapView based on MapLibre Native. This is configurable with a number of constructor parameters. If the existing customizations don’t work for you, first we’d love to hear why via an issue on GitHub! In the case that you want complete control though, the map view itself is actually not that complex.

TODO: Passing a composable to add layers to the map (WIP)

Style

The demo app uses the MapLibre demo tiles, but you’ll need a proper basemap eventually. Just pass in the URL of any MapLibre-compatible JSON style. See the vendors page for some ideas.

Camera

TODO: Ability to override the built-in camera behavior (probably define a protocol for this).

Customizing the instruction banners

Ferrostar includes a number of views related to instruction banners. These are composed together to provide sensible defaults, but you can customize a number of things.

Distance formatting

By default, banners and other UI elements involving distance will be formatted using the bundled com.stadiamaps.ferrostar.composeui.LocalizedDistanceFormatter. Distance formatting is a complex topic though, and there are ways it can go wrong.

The Android ecosystem unfortunately does not include a generalized distance formatter, so we have to roll our own. Java locale does not directly specify which units should be preferred for a class of measurement.

We attempt to infer the correct measurement system to use, using some newer Android APIs. Unfortunately, Android doesn’t really have a facility for specifying measurement system independently of the language and region setting. So, we allow overrides in our default implementation.

If this isn’t enough, you can implement your own formatter by implementing the com.stadiamaps.ferrostar.composeui.DistanceFormatter interface.

If you find an edge case, please file a bug report (and PR if possible)!

The com.stadiamaps.ferrostar.composeui.BannerInstructionView composable comes with sensible defaults, with plenty of override hooks. The default behavior is to use Mapbox’s public domain iconography, format distances using the device’s locale preferences, and use a color scheme and typography based on the Material theme.

You can pass a customized distance formatter as noted above, and you can also override the theme directly if you’d like more control than our defaults derived from the Material theme.

Finally, you can override the leading edge content. Just write your own composable content block rather than accept the default.

If you need even more control, you can use the com.stadiamaps.ferrostar.composeui.ManeuverInstructionView directly, or write your own, optionally using the MapboxManeuverIcon.

Architecture Overview

Ferrostar is organized in a modular (hexagonal) architecture. At the center sits the core, which is more or less purely functional (no mutable state). This is great for testability and portability. In the extreme on the portability side, we Ferrostar should build for WASM and many embedded architectures, though no frontends currently exist.

One level up is a binding layer. This is generated using UniFFI. These bindings are usually quite idiomatic, taking advantage of language-specific features like enums, data classes, and async support.

Platform libraries add higher level abstractions, state management, UI, and access to platform APIs like device sensors. As much as possible, we try to keep the API similar across platforms, but occasional divergences will pop up here to accommodate platform norms (ex: a single "core delegate" in Swift vs multiple interfaces in Kotlin).

Breaking down the responsibilities by layer:

  • Core (Rust)
    • Common data models
    • Request generation and response parsing for APIs (ex: Valhalla and OSRM)
    • Spatial algorithms like line snapping and distance calculations
    • Navigation state machine (which step are we on? sholud we advance to the next one? etc.)
  • Bindings (UniFFI; Swift and Kotlin)
    • Auto-generated direct bindings to the core (models, navigation state machine, etc.)
  • Platform code (Swift and Kotlin)
    • Higher-level imperative wrappers
    • Platform-native UI (SwiftUI / Jetpack Compose + MapLibre)
    • Interface with device sensors and other platform APIs
    • Networking

As in any hexagonal architecture, you can't skip across multiple layer boundaries.

The Ferrostar Architecture Diagram

Route Providers

Route providers expose common interfaces for making requests to a routing engine and getting the data back in a standardized format for navigation. This layer of indirection makes Ferrostar extremely extensible.

RouteProvider

There are two types of route providers: one more suited to HTTP servers, and another designed for other use cases like local route generation. The core ships with common implementations, but you’re free to define your own in your application code without waiting for a PR to land or running a custom fork!

Route Adapters

A route adapter is the first class of route provider. It is designed for HTTP, sockets, and other request/response flows. A RouteAdapter is typically nothing more than the composition of two halves: a RouteRequestGenerator and a RouteResponseParser (both of which are traits at the Rust level; protocols and interfaces at the platform level). You can mix and match these freely (though it probably goes without saying that you won’t get very far swapping out the wrong parser for a server type).

Let’s illustrate why this architecture is nice with an example. Valhalla can generate responses in multiple formats. The default is its own JSON format, but it also has more compact Protobuf and (much) more verbose OSRM serializers.

The OSRM serializer is the one that’s typically used for navigation use cases, so with a single RouteResponseParser implementation, we can parse responses from a Valhalla server or an OSRM server! In fact, we can also parse responses from the Mapbox Directions API too! Both Valhalla and Mapbox have OSRM serializers that add a few extra fields which are useful for navigation applications. The OSRM parser in the core of Ferrostar handles all of these “flavors” gracefully.

OSRM parser in hand, all that we need to do to support these different APIs is a RouteRequestGenerator for each. All three speak HTTP, but they have different request formats. Writing a RouteRequestGenerator is pretty easy though. Request generators return a sum type (enum/sealed class) indicating the type of request to make (only HTTP POST is supported at this time) and associated data like the URL, headers, and request body.

Here’s a sequence diagram illustrating the flow of data between components. Don’t worry too much about the internal complexity; you’ll only interface with FerrostarCore at the boundary.

sequenceDiagram
    FerrostarCore->>+RouteAdapter: generateRequest
    RouteAdapter-->>+FerrostarCore: RouteRequest
    FerrostarCore-)Routing API: Network request
    Routing API--)FerrostarCore: Route response (bytes)
    FerrostarCore->>+RouteAdapter: parseResponse
    RouteAdapter->>+FerrostarCore: [Route] or error
    FerrostarCore->>+Application Code: [Route] or error

Bundled support

The Ferrostar crate includes support for the following out of the box.

Valhalla

Valhalla APIs are supported out of the box with full request and response parsing.

As a bundled provider, FerrostarCore in the platform layer exposes convenience initializers which automatically configure the RouteAdapter.

If you’re rolling your own integration or curious about implementation details, the relevant Rust type is ValhallaHttpRequestGenerator.

For response parsing, we use the OSRM format at this time. You can construct an instance of ValhallaHttpRequestGenerator directly (if you’re using Rust for your application) or using the convenience method createValhallaRequestGenerator from Swift or Kotlin.

OSRM

OSRM has become something of a de facto linga franca for navigation APIs. Ferrostar comes bundled with support for decoding OSRM responses, including the common extensions developed by Mapbox and offered by many Valhalla servers. This gives drop-in compatibility with a wide variety of backend APIs, including the hosted options from Stadia Maps and Mapbox, as well as many self-hosted servers.

The relevant Rust type is OsrmResponseParser. The autogenerated FFI bindings expose a createOsrmResponseParser method in case you want to roll your own RouteAdapter for an API that uses a different request format but returns OSRM format responses.

Implementing your own

If you’re working with a routing engine and want to see it directly supported in Ferrostar, we welcome PRs! Have a look at the existing Valhalla (request generator) and OSRM (response parser) implementations for inspiration.

If you’d rather keep the logic to yourself (ex: for an internal API), you can implement your own in Swift or Kotlin. Just implement/conform to one or both of RouteRequestGenerator and RouteResponseParser in your Swift and Kotlin code.

You may only need to implement one half or the other. For example, to integrate with an API that returns OSRM responses but has a different request format, you only need a RouteRequestGenerator; you can re-use the OSRM response parser.

CustomRouteProvider

Custom route providers are most commonly used for local route generation, but they can be used for just about anything. Rather than imposing a clean (but rigid) request+response model with opinionated use cases like HTTP in mind, the custom route provider is just a single-method interface (SAM for you Java or Kotlin devs) for getting routes asynchronously.

Here’s a sequence diagram illustrating the flow of data between components when using a CustomRouteProvider.

sequenceDiagram
    FerrostarCore-)CustomRouteProvider: getRoutes
    CustomRouteProvider--)FerrostarCore: [Route] or error

Using a RouteProvider

The parts of Ferrostar concerned with routing are managed by the FerrostarCore class in the platform layer. After you create a RouteProvider and pass it off to FerrostarCore (or use a convenience constructor which does it all for you!), you’re done. Developers do not need to interact with the RouteProvider after construction. FerrostarCore provides a high-level interface for getting routes, which is as close as you usually get to them.

sequenceDiagram
    Your Application-)FerrostarCore: getRoutes
    FerrostarCore-)RouteProvider: (Hidden complexity)
    RouteProvider--)FerrostarCore: [Route] or error
    FerrostarCore--)Your Application: [Route] or error

This part of the platform layer follows the Hollywood principle. This provides elegant ways of configuring rerouting, which we’ll cover next.

Location Providers

Location providers do what you would expect: provide locations! Location providers are included in the platform libraries, since they need to talk to the outside world.

Location can come from a variety of sources. If you're somewhat experienced building mobile apps, you may think of CLLocationManager on iOS, or the FusedLocationProviderClient on Android. In addition to the usual platform location services APIs, location can also come from a simulation or a third-party location SDK such as Naurt.

To support this variety of use cases, Ferrostar introduces the LocationProvider protocol (iOS) / interface (Android) as a common abstraction. We bundle a few implementations to get you started, or you can create your own.

SimulatedLocationProvider

The SimulatedLocationProvider allows for simulating location within Ferrostar, without needing GPX files or complicated environment setup. The usage patterns are roughly the same on iOS and Android:

  1. Create an instance of the SimulatedLocationProvider class.
  2. Set a location or a Route.
  3. Let the FerrostarCore handle the rest.

To simulate an entire route from start to finish, use the higher level setSimulatedRoute function to preload an entire route, which will be "played back" automatically when there is a listener attached. You can control the simulation speed by setting the warpFactor property.

NOTE: While the SimulatedLocationProvider is defined in the platform library layer, the simulation functionality comes from the functional core and is implemented in Rust for better testability and guaranteed consistency across platforms.

If you want low-level control instead, you can just set properties like lastLocation and lastHeading directly.

You can grab a location manually (ex: to fetch a route; both iOS and Android provide observation capabilities). FerrostarCore also automatically subscribes to location updates during navigation, and unsubscribes itself (Android) or stops location updates automatically (iOS) to conserve battery power.

"Live" Location Providers

Ferrostar includes the following live location providers:

  • iOS
    • CoreLocationProvider - Location backed by a CLLocationManager. See the iOS tutorial for a usage example.
  • Android
    • TODO: Provider backed by the Google Fused Location Client
    • TODO: AOSP provider

Configuring the Navigation Controller

TODO

StepAdvanceMode

RouteDeviationTracking

Commercial Vendors

Here is a list of commercial vendors with APIs that are currently supported by Ferrostar. PRs welcome to expand the list or to add support!

Routing

  • Stadia Maps - Valhalla-based API, either hosted (free keys for evaluation available) or on prem
  • GIS • OPS - Valhalla or OSRM; custom deployments and integration of proprietary data

Basemaps

You need basemaps for most (but not all!) useful navigation apps.

The included UI components are all based on MapLibre, which supports the Mapbox Vector Tile specification. Here are a few popular vendors:

At the time of this writing, all basemap vendors offer free API keys for evaluation use.