Android, iOS and WASM from one Rust core

# Android, iOS and WASM from one Rust core

> **Note, updated 3 August 2026.** The one-core argument is unchanged, but the release facts below have moved on. There are five SDKs now, `@mapmap/react-native` having joined them. `ai.mapmap:core` is on Maven Central as `ai.mapmap:core`, with no token needed, rather than pending at GA. And every version in the table has advanced. See [SDKs and installation](/docs/sdks) for the current table.

The MapMap Android SDK, the iOS SDK and `@mapmap/core` are not three navigation engines. They are three sets of bindings over one Rust core, generated rather than hand-written, published from the same `sdk-v*` git tags. Kotlin and Swift reach the core through UniFFI 0.31 exports under the namespace `mapmap`; Node reaches the same code compiled to WebAssembly. The point of the arrangement is narrow and it is the whole reason we built it this way: the ADR tunnel decision a driver sees offline on an Android head unit comes out of the same Rust crate, `sn-adr`, that the hosted gateway calls.

## Why one core instead of three hand-written engines?

Because navigation drift is a compliance defect, not a cosmetic bug. If the device and the server disagree about whether a truck may use a tunnel, one of them is wrong, and the reader who finds out is a driver at a tunnel mouth with a load of petrol behind them.

Three hand-written implementations of "may this load enter a category D tunnel" means three readings of ADR 8.6.4, three test suites and three release cadences. They do not drift because anyone is careless. They drift because a fix lands in Kotlin on a Tuesday and the Swift port is a ticket, and the ticket is fine until the month it is not. The failure is quiet: the route looks plausible, the distance is plausible, and the illegal edge is 60 kilometres in.

So we wrote the tunnel logic once, in `sn-adr`, and let the toolchain make the copies. `sn-adr` is the single source of truth for ADR 8.6.4 semantics across the platform. `sn-nav-core` re-exports it for the mobile and WASM bindings, and the gateway depends on it directly, so neither one owns a second reading of the table. `checkTunnel` in JavaScript, Kotlin and Swift (UniFFI generates the camelCase name from Rust's `check_tunnel`), plus `POST /adr/check` on the gateway, are one implementation with four doors. The bindings are build output. Nobody reviews a Swift pull request that reinterprets the tunnel table, because there is no Swift implementation of the tunnel table to reinterpret. The longer argument for owning the core at all is in [why we built our own routing core in Rust](/news/why-our-own-routing-engine).

## What ships, and what does each one need?

Four SDKs, published from tagged `sdk-v*` releases. Three are bindings over the Rust core. The fourth, `@mapmap/maps`, is a TypeScript wrapper over MapLibre GL JS rather than a binding, and it is a different story.

| SDK | Package | Version | Platform floor | Peer requirements |
| --- | --- | --- | --- | --- |
| Core (WASM) | `@mapmap/core` | 0.1.0 | Node (the npm artefact is the Node build) | none |
| Android | `ai.mapmap:core` | 0.1.0 | minSdk 26 | none |
| iOS | `MapMapKit` | per `sdk-v*` release | iOS 16.4, Swift tools 5.9 | none |
| Web maps | `@mapmap/maps` | 0.1.0 | Node >= 18 to build, ESM only | `maplibre-gl` >= 4 < 6, `pmtiles` >= 3 < 5 |

The iOS package's FFI layer is a prebuilt binary XCFramework. Each `sdk-v*` release attaches the zipped XCFramework and a generated `Package.swift` that references it as a checksummed `binaryTarget`, so the binary your build resolves is the binary we tagged. An optional `MapMapValhalla` product adds the on-device Valhalla routing engine on iOS 16.4 and up. Android publishes to GitHub Packages from the same tags and moves to Maven Central as `ai.mapmap:mapmap-sdk` at GA.

## What does the WASM core give you without a map?

It gives you the compliance and guidance logic with no renderer attached: ADR tunnel checks, offline route-request building, route parsing, polyline codecs, and the same Ferrostar-derived guidance state machine the mobile SDKs run.

```js
const { checkTunnel, buildOfflineRouteRequest } = require("@mapmap/core");

// May this load use a category D tunnel? (ADR 8.6.4, worst-case reading)
checkTunnel({ hazmat: true, tunnelCode: "C" }, "D");
// => { status: "blocked", reason: "..." } or { status: "allowed" }

// Build a turn-by-turn request any Valhalla endpoint accepts:
// hosted API, self-hosted server, or an on-device engine.
const body = buildOfflineRouteRequest(
[{ lat: 51.1279, lon: 1.3134 }, { lat: 52.4862, lon: -1.8904 }],
"truck",
{ heightM: 4.0, grossWeightT: 40, hazmat: true, tunnelCode: "D" },
);
```

The rest of the surface: `version()`, `forbiddenCategories(profile)`, `parseRouteSummary(json)` (which returns `{ distanceM, durationS, hasToll, hasHighway, hasFerry }`), `polylineDecode` and `polylineEncode` at precision 5 or 6, and `GuidanceSession`. You construct a `GuidanceSession` from a route response, stream fixes in with `updateLocation(lat, lon, timestampMs, …)`, and each call returns the updated `{ state: "navigating" | "arrived" | "offRoute", … }`. That state machine is the same Rust source on Android and iOS, compiled per platform rather than reimplemented, which is the entire argument in one sentence.

## How does the verifying key stop a tampered territory package?

The mobile SDKs pin a 64-hex ed25519 verifying key at build time, and a territory package that does not verify against it is rejected on device before anything is promoted onto disk. Territory packages (the offline maps the SDKs consume) are signed with the factory signing key; the verifying key is the public half of that pair. The pinning call is the `TerritoryStore` constructor.

```kotlin
import ai.mapmap.territory.TerritoryStore

// 64-hex factory verifying key, baked in at build time (e.g. BuildConfig).
// Never fetched at runtime.
val store = TerritoryStore(
rootDir = File(context.filesDir, "territories"),
verifyingKeyHex = BuildConfig.MAPMAP_FACTORY_PUBKEY_HEX,
)
```

```swift
import MapMapKit

let store = TerritoryStore(rootDir: territoryDir, verifyingKeyHex: mapMapFactoryPubkeyHex)
```

Two rules matter more than the code. Bake the key in at build time, as a build config field or a bundled resource. Never fetch it over the same channel as the packages it validates, because a key delivered by the thing it is meant to police is not a trust anchor, it is decoration. Every install is verify-then-promote, so a package that fails signature verification leaves no trace on disk.

Where the key comes from depends on who runs the factory. Hosted customers receive the factory verifying key with SDK early access. [Self-host](/docs/self-host) operators generate their own pair with `snfactory keygen` and pin their own public key, which is the concrete thing that makes a deployment yours. The web SDK streams tiles from the API rather than verifying packages in the browser, so pinning applies to the mobile SDKs only. How the packages are built, signed and updated over the air is covered in [offline navigation](/news/offline-navigation).

## What does the SDK licence cost: per vehicle or per MAU?

Two models, and which one applies depends on whether you run vehicles or ship an app. Fleets license per vehicle per month, billed annually and invoiced, so it is not self-serve.

| Devices | Core, £/vehicle/month | Plus, £/vehicle/month |
| --- | --- | --- |
| 500 | 3.00 | 5.00 |
| 2,500 | 2.40 | 4.00 |
| 10,000 | 1.80 | 3.00 |
| 25,000+ | 1.20 | 2.20 |

Core is offline navigation, ADR restrictions enforced offline and territory updates for one region. Plus adds additional regions and priority support. Both include production offline territory downloads and ongoing OTA updates. Contact hello@mapmap.ai.

App developers meter per monthly active user instead, self-serve:

| Item | Value |
| --- | --- |
| Included MAU, free tier | 1,000 |
| Each new distinct user beyond | 30p, debited once at first sight |
| Starter plan includes | 5,000 MAU |
| Growth plan includes | 25,000 MAU |
| Scale plan includes | 100,000 MAU |
| Metered by | the `X-MapMap-User` request header |

Your app sends an opaque `X-MapMap-User` header (8 to 128 printable ASCII characters) on its gateway requests, and the gateway counts distinct values per identity per calendar month. Hash an install id. Never send PII; the gateway hashes the value again before storing it.

```sh
curl -fsS -H "Authorization: Bearer $API_KEY" \
-H "X-MapMap-User: 9f2c4a1e8b7d3f60" \
"$BASE/route/v1/truck/1.3134,51.1279;-1.8904,52.4862"
```

No header means no MAU counting, which is the normal state for server-to-server API use. Self-host operators control the same numbers with `SN_MAU_INCLUDED` (default 1000) and `SN_PRICE_PER_MAU_PENCE` (default 30), and `SN_PRICE_PER_MAU_PENCE=0` disables MAU billing entirely.

## Why is MAU billing fail-open?

Because the person a hard stop would punish is not the person who forgot to top up. When a new user cannot be covered by your prepaid balance, the request is served anyway. The identity is flagged `mau_overdrawn` for the month, visible on `GET /v1/keys/self`, and the gateway keeps answering.

The alternative fails in exactly the wrong shape. A MAU debit only fires when a user is seen for the first time that month, so a fail-closed design would refuse the first request a brand new user ever makes. Their first experience of your app would be a navigation screen that does not load, caused by a finance detail two companies away from them.

We would rather send you an invoice you did not expect than break navigation for someone who has no relationship with our billing at all. The flag is the enforcement mechanism, and the request goes through.

## What does not work yet

- **The mobile SDKs need early access.** The web packages install from npm today. The Android AAR and the iOS XCFramework ship from the same `sdk-v*` tags, but the source repository is private until sdk-v1, so installing them means emailing us first. "Self-hostable" and "ask us for access" sitting in one paragraph is friction, and we know it.
- **Android needs a token even once it is public.** GitHub Packages requires authentication for public packages, so you need a GitHub account with repository access and a personal access token carrying the `read:packages` scope, via `gpr.user` / `gpr.token` in `~/.gradle/gradle.properties`. That goes away at GA when the SDK moves to Maven Central.
- **The SDKs do not set `X-MapMap-User` automatically.** If you are metering per MAU, send the header from your own networking layer. This is on us to fix.
- **`@mapmap/core` on npm is the Node build.** If you need a browser bundle today, that is a conversation rather than an install.
- **Fail-open covers MAU debits, not call credit.** Running out of prepaid credit still returns 402 or 429 on the API call itself. MAU billing can never block or 402 a request; the call quota can and will. Do not read one guarantee as the other.

## Try it

Install `@mapmap/core` and run `checkTunnel` against a category D tunnel in about a minute, no key required, because the compliance logic is local. When you want the mobile bindings, the install steps, coordinates and the verifying-key contract are all on the [SDKs reference page](/docs/sdks), and source access for the private repository is an email to hello@mapmap.ai.

Routing derives from OpenStreetMap, so credit © OpenStreetMap contributors when you render or republish it.