@mapmap/react-native
v0.3.2
Published
MapMap React Native SDK — Expo module for fully offline turn-by-turn navigation: signed territory install/lifecycle, on-device routing, a typed guidance event stream and voice guidance.
Maintainers
Readme
@mapmap/react-native
The official MapMap React Native SDK — an Expo module for fully offline turn-by-turn navigation:
- Territory lifecycle — list, install (signed + verified), remove, and style offline territory packages; progress events throughout.
- Routing — on-device route computation with typed geometry + maneuvers (no Valhalla JSON re-parsing in app code).
- Guidance — a typed event stream (
navigating/rerouting/arrived, banner instructions, ETA, off-route) driven by live GPS or a.drive.jsonlreplay corpus. - Voice — spoken guidance with mute/volume control and a
speakingstate event. - Mock mode —
getMapmapNav(true)returns a pure-TS simulator with the identical surface, for Expo Go, Storybook-style UI work and unit tests.
Status: TypeScript surface stable; both native bridges compile in CI against the shipped SDKs. This package is the bridge (~2,000 lines across TS/Swift/Kotlin) extracted from a real customer app and promoted to the official module. The TypeScript surface, including the mock, is stable, tested and usable today.
The iOS bridge is built in CI against the real published
MapMapKit(see iOS status): every file that calls the SDK compiles against the actual distribution package, not a stub.The Android bridge was rewritten against the real
ai.mapmap:coreAPI and is compiled against the artefact on Maven Central by thereact-native-androidCI workflow on every change; parts of the contract the shipped core cannot serve reject loudly rather than fake a result (see Android status).
Requirements
| | |
|---|---|
| iOS | 16.4+, MapMapKit 0.6.x via the public Mapmapai/mapmap-ios distribution repo (SwiftPM, or the binary MapMapKit.podspec). 0.6.0 is the release the bridge is compiled against in CI; older tags are not supported (0.2.x does not build at all) |
| Android | minSdk 26 (above Expo's default — see below), ai.mapmap:core 0.3.0 from Maven Central — no credentials needed |
| React Native | Expo SDK 52+ (config plugin), or bare React Native 0.74+ with expo-modules-core installed |
Install — Expo
npx expo install @mapmap/react-nativeRegister the config plugin in app.json / app.config.js:
{
"expo": {
"plugins": [
[
"@mapmap/react-native",
{
"locationWhenInUsePermission": "Shown to the user when asking for foreground location.",
"locationAlwaysPermission": "Shown when asking for background location."
}
]
],
"android": {
// minSdk 26: the MapMap core requires it; Expo's default is lower.
// With expo-build-properties:
// ["expo-build-properties", { "android": { "minSdkVersion": 26 } }]
}
}
}The plugin writes the NSLocation*UsageDescription Info.plist keys the SDK's
CoreLocationProvider needs (the SDK deliberately declares none of its own)
and enables the location + audio background modes so guidance and voice
keep running with the screen off.
Then build a dev client (npx expo run:ios / run:android). The native
module is not available in Expo Go — use the mock there
(getMapmapNav(true)). Importing this package never requires the native
module to be present: it is resolved lazily, on first use of the native
handle, so the mock path works anywhere (Expo Go, web, unit tests).
Android repositories — nothing to configure
ai.mapmap:core is on Maven Central from 0.3.0, so the mavenCentral()
that every Expo/RN template already declares resolves the core and its
dependencies anonymously. No GitHub account, no read:packages PAT.
To pin a different core:
./gradlew ... -Pmapmap.coreVersion=0.3.1Versions before 0.3.0 were only ever published to GitHub Packages. If you
deliberately pin one, supply credentials in ~/.gradle/gradle.properties (or
as GPR_USER / GPR_TOKEN env vars) and the module will register that
repository for you, scoped to ai.mapmap artefacts only:
gpr.user=<github username>
gpr.token=<PAT with read:packages>Without those properties the repository is not registered at all — which is
what keeps a default build credential-free, and keeps it working under the
RepositoriesMode.FAIL_ON_PROJECT_REPOS set by current RN/Expo templates.
iOS binary SDK
The pod depends on MapMapKit (~> 0.6.0), the prebuilt binary of the
MapMap iOS SDK (no vendoring, no Valhalla/boost recompiles). Until it is on
the CocoaPods CDN, pin it in your Podfile:
pod 'MapMapKit', podspec: 'https://raw.githubusercontent.com/Mapmapai/mapmap-ios/main/MapMapKit.podspec'On-device routing needs one more package. computeRoute runs Valhalla on
the device through the SDK's MapMapValhalla product, which ships only via
SwiftPM — there is no binary pod for it yet. Add the SwiftPM package to your
app target (File → Add Package Dependencies… →
https://github.com/Mapmapai/mapmap-ios, link MapMapValhalla). Without
it the bridge still builds and everything else works, but computeRoute
rejects with E_INTERNAL rather than silently pretending.
Install — bare React Native
npm install @mapmap/react-native expo-modules-core—expo-modules-coreis the only required peer; theexpopackage itself is optional and is not imported at runtime.- Follow the Expo modules install guide
(adds
use_expo_modules!to the Podfile and the Gradle settings plugin). - iOS:
cd ios && pod install(with theMapMapKitPodfile line above). - Android: set
minSdkVersion 26inandroid/build.gradle. No repository configuration is needed beyond the template'smavenCentral(). - Apply the Info.plist keys / background modes manually (the config plugin
only runs under Expo prebuild):
NSLocationWhenInUseUsageDescription,NSLocationAlwaysAndWhenInUseUsageDescription, andUIBackgroundModeslocation+audio.
Usage
import {
getMapmapNav,
addGuidanceListener,
addTerritoryProgressListener,
} from "@mapmap/react-native";
const nav = getMapmapNav(false); // true → pure-TS mock (Expo Go, tests)
await nav.init("YOUR_MAPMAP_API_KEY");
// Territories: list, install (signed + verified), style offline
const available = await nav.listAvailableTerritories();
const sub = addTerritoryProgressListener(nav, (e) =>
console.log(e.territoryId, e.phase, e.bytesDone, e.bytesTotal)
);
await nav.installTerritory("uk");
const styleJson = await nav.getTerritoryStyle("uk", "dark"); // MapLibre style, all-local refs
// Routing — typed geometry + maneuvers
const { routes } = await nav.computeRoute({
profile: "car",
origin: { latitude: 51.5074, longitude: -0.1278 },
destination: { latitude: 51.5081, longitude: -0.0759 },
});
// Guidance — typed event stream
addGuidanceListener(nav, (e) => {
console.log(e.state, e.banner?.primary, e.remainingDistanceMeters, e.etaEpochMs);
});
await nav.startGuidance({ routeIndex: 0, source: { kind: "live" } });
// Voice
await nav.setVoiceMuted(false);
await nav.setVoiceVolume(0.8);The full surface (all types, error codes, events) is defined in
src/MapmapNav.types.ts — that file is the
bridge contract, implemented exactly on both platforms.
Errors
Every rejected promise carries a stable machine-readable code
(E_NOT_INITIALISED, E_AUTH, E_TERRITORY_VERIFY, E_NO_ROUTE, …) plus a
human message; the same shape is emitted on the bridgeError event. See
BridgeErrorCode in the types file.
Development
npm install
npm run typecheck # tsc --noEmit
npm test # vitest (mock + event helpers; no native runtime needed)
npm pack --dry-run # what shipsiOS status
The Swift bridge in ios/ compiles against the real published
MapMapKit. That is a build result, not an assertion: ios/Package.swift is
a compile-check manifest that resolves
Mapmapai/mapmap-ios at the pinned
release and builds these sources against it, and the ios-bridge-compile job
in .github/workflows/publish-react-native.yml runs it on every PR. Locally:
cd react-native/ios
xcodebuild -scheme MapmapNavBridge -destination 'generic/platform=iOS Simulator' buildWhat is compiled: every file that calls MapMapKit — MapmapNavCore.swift,
GuidanceMapping.swift, RouteMapping.swift, GatewayLayerFetcher.swift,
BridgeError.swift, Support.swift. What is not:
MapmapNavModule.swift, which imports ExpoModulesCore (no SwiftPM
distribution — compiling it needs a full CocoaPods + React Native pod
install). That file contains no MapMapKit symbols; it is a pure Expo Module
shim forwarding to MapmapNavCore.
Before this was checked, the bridge did not compile. It was written
against MapMapKit 0.2.0, and 0.2.x is not a buildable package at all — those
tags ship Swift sources referencing UniFFI-generated types (GuidanceUpdate,
CostingModel, LayerFetcher, RoutePoint, …) that are absent from the tag;
the generated bindings first shipped in 0.3.0, and no MapMapKit.podspec
existed in the distribution repo before then either. Against 0.6.0 the
divergences were:
| Bridge had | Shipped SDK |
|---|---|
| GatewayLayerFetcher: LayerFetcher with no import MapMapKit | LayerFetcher is a MapMapKit type — hard compile error |
| case .navigating matched with 10 associated values | 11 (durationRemainingS was missed) |
| ETA estimated as distance ÷ speed, falling back to "50 km/h" | durationRemainingS is reported by the core |
| raw GPS fix used for the puck | SnappedFix (route-snapped position + course) |
| banner arrow hardcoded to straight | VisualBanner.primary.maneuverType/maneuverModifier |
| search() rejected: "no on-device geocoder exists" | TerritoryStore.openSearch → TerritorySearch.search |
| hand-written MapLibre style, glyphs on a public HTTPS URL | TerritoryStore.territoryStyle(territoryId:theme:), all-local |
| Valhalla JSON re-parsed by hand (own polyline decoder, length × 1000) | RouteResult.summary/.geometry/.maneuvers, typed and in SI |
| errors classified by substring-matching String(describing:) | NavCoreError / FetchError cases, switched on by type |
Known gaps on iOS, stated rather than faked:
- On-device routing needs the SwiftPM
MapMapValhallaproduct (see above). The import sits behind#if canImport, so the pod builds without it;computeRoutethen rejects withE_INTERNAL. BannerInstruction.roundaboutExitand the "Then ↰" chip (thenPrimary/thenManeuverType) are never set during guidance. The core's banner carriesroundaboutExitDegrees(degrees travelled, not an exit ordinal) and asubline holding the lane diagram, not the next manoeuvre. Both contract fields are optional, so they are left absent. (roundaboutExitis populated oncomputeRoutemanoeuvres, where the SDK reportsroundaboutExitCount.)listAvailableTerritoriesandinstallTerritorytalk to gateway paths (/v1/territories…) that the compile check cannot exercise; they are verified as Swift, not as an integration.
Android status
The Android bridge in android/src/main compiles against the published
ai.mapmap:core (issue #448). It was previously written against an assumed
API and did not compile against 0.2.0 or 0.3.0; the only check that ran was
-PskipMapmap, which substituted a hand-written android/src/stub/java whose
own header admitted it encoded the assumed surface. Those stubs are
deleted and -PskipMapmap is gone. In their place, the
react-native-android CI workflow packs this npm package, installs it into a
freshly generated Expo app and compiles the module with Gradle against
ai.mapmap:core from Maven Central, on every change to android/.
Kotlin metadata
ai.mapmap:core 0.3.0 is published with Kotlin 2.3 metadata, which the
Kotlin plugin current Expo/RN templates pin (2.1.x on SDK 54/57) refuses to
read. android/build.gradle therefore compiles this module — and only this
module — with -Xskip-metadata-version-check. Remove that once the core is
published with metadata a stock template reads.
What the shipped core cannot do
These are contract gaps, not bridge shortcuts. Nothing below is faked: each one rejects with a contract error code and a message naming the reason.
| Contract | Android behaviour on core 0.3.0 |
|---|---|
| installTerritory(id) for a territory that is not yet installed | Rejects E_INTERNAL. The core installs only from a local .snpkg or package directory (installSnpkg / installDir); checkForUpdate errors outright when the territory is not installed, and the hosted channel serves an index, manifests and layer blobs but no package archive. Updating an already-installed territory works fully (signed differential update, verify-then-promote). |
| RouteRequest.alternatives (0–3, default 2) | Ignored. OfflineRouting.route() returns exactly one RouteResult and the core exposes no alternates API, so RouteResult.routes always has length 1. |
| GuidanceEvent.state === "rerouting" | Reported when the core emits OffRoute, but the bridge does not recompute: an engine is bound to one route, so the app must call computeRoute + startGuidance again. |
| BannerInstruction.roundaboutExit / thenPrimary / thenManeuverType | Emitted only when the route's maneuver list is index-aligned with the core's guidance steps (checked against NavigationEngine.totalSteps()), because the core's sub-banner is the lane diagram, not the next instruction. Omitted otherwise rather than guessed. |
| stopGuidance() silencing speech immediately | VoiceGuidance has no pause — close() shuts the TTS engine down permanently — so the module holds one instance for its lifetime and stops feeding it. An utterance already handed to the platform engine finishes. |
| TerritorySummary.name / sizeBytes | Not in the signed channel index (ids and versions only), so listAvailableTerritories() fetches each territory's package manifest for the display name and layer byte total: one extra GET per territory. |
Everything else in the contract is implemented against real core APIs:
territory listing/removal/activation, offline style JSON
(TerritoryStore.territoryStyle), on-device search
(TerritoryStore.openSearch → TerritorySearch.search), on-device routing
(OfflineRouting.route over ValhallaMobileRouter.fromTileDir), guidance
(NavigationEngine.navigate) and voice, including the live speaking state.
android/build.gradle needs the territory channel's ed25519 verifying key
baked in at build time via the mapmap.factoryPubkeyHex Gradle property or
MAPMAP_FACTORY_PUBKEY_HEX env; init() rejects E_INIT_FAILED when it is
missing rather than failing later inside the core.
Native follow-ups tracked for this package:
- Give the core a channel-driven first install (or serve a
.snpkg) soinstallTerritoryworks end to end. - Ship
MapMapValhallaas a binary pod so on-device Valhalla routing links without SwiftPM (todayMapmapNavCore.swiftimports it from the SwiftPM package, behind#if canImport). - Run the iOS bridge against a real device/simulator with an installed territory: the CI job proves it compiles, not that a drive works.
Licence
Commercial — see LICENSE. Requires a MapMap API key (mapmap.ai).
