@movexgeo/react-native-movex
v2.0.1
Published
React Native bridge for movex.geo. Wraps the native Android (Kotlin) + iOS (Swift) SDKs as a TurboModule with TypeScript types.
Downloads
305
Maintainers
Readme
@movexgeo/react-native-movex
React Native bridge for movex.geo — background location, geofencing, and motion-aware tracking on iOS and Android. Wraps the native Kotlin and Swift SDKs as a TurboModule with TypeScript types.
- Min RN: 0.73 (TurboModules + Bridgeless)
- Min platforms: Android API 24 (Android 7) / iOS 14
- Architecture: New Architecture supported; legacy bridge falls back automatically
What's new in 2.0.0:
ACTIVITY_RECOGNITIONis now host-opt-in (avoids Play's Health Apps classification by default). SeeCHANGELOG.md.
Looking for the native SDKs directly? See
sdk/androidandsdk/ios.
Install
yarn add @movexgeo/react-native-movex
# or
npm install @movexgeo/react-native-movexThe package autolinks on RN ≥ 0.60. After install:
# iOS — install Pods
cd ios && pod install && cd ..
# Android — nothing extra; the manifest merger pulls our permissions inGet a location — no key needed
captureCurrentPosition is pre-init-safe — it works without an API key and without calling initialize(). All it needs is the location permission. Use it for delivery marking, OTP/RVP scans, any moment where you need a precise one-shot fix without the full tracking setup.
import MovexGeo from '@movexgeo/react-native-movex';
// No initialize() call, no API key — just permissions granted.
const fix = await MovexGeo.captureCurrentPosition();
console.log(fix.lat, fix.lng, fix.accuracy_m);Options:
const fix = await MovexGeo.captureCurrentPosition({
enable_high_accuracy: true, // default: true — highest chip accuracy
timeout_ms: 10_000, // default: 10 s — raise for cold-start GPS indoors
});Returns a LocationFix. Does not POST the fix to the server — it is purely a device-side capture. To also record it server-side, follow up with trackOnce(fix.lat, fix.lng, fix.accuracy_m) (which does require initialize()).
Rejects with MovexGeoError { code: 'permission_denied' } if location permission is not granted, and { code: 'location_timeout' } if the chip doesn't return a fix within timeout_ms.
Full tracking quick start
For background tracking with geofences and server events, you need an API key:
import MovexGeo from '@movexgeo/react-native-movex';
// 1. Initialize once at app startup. Sync, no network.
await MovexGeo.initialize('pub_live_<your-key>');
// 2. Associate fixes with a user (optional but recommended)
await MovexGeo.setUserId('u_42');
// 3. Verify the key resolves and pull project info
const info = await MovexGeo.verify();
console.log(info.project_id, info.tier); // "prj_…" , "paid" | "shared" | "local"
// 4. One-shot location capture — also posts the fix to the server
const r = await MovexGeo.trackOnce(12.97, 77.59, 12);
console.log('accepted:', r.accepted, 'nearby fences:', r.nearby_fences?.length);For the background loop with motion gating:
// Subscribe BEFORE startTracking so you don't miss early events.
const sub = MovexGeo.onEvents(events => {
for (const e of events) {
console.log(e.event_type, e.event_id); // user.entered_geofence, etc.
}
});
await MovexGeo.startTracking('responsive');
// later (e.g. on logout or shift end)
await MovexGeo.stopTracking();
sub.remove();Platform setup
iOS — Info.plist
Add the location strings users see in the OS permission prompt. Without these, CLLocationManager silently denies the request.
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to track deliveries / rides / etc.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We track your location in the background to deliver real-time updates even when the app is closed.</string>
<!-- Required for background location updates -->
<key>UIBackgroundModes</key>
<array>
<string>location</string>
<string>fetch</string>
</array>App Store review: Apple rejects "Always" requests without a clear user benefit — pair the prompt with explanatory in-app UI.
Android — manifest
All required permissions are declared in the SDK's manifest and merged into your app automatically (INTERNET, ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION, ACCESS_BACKGROUND_LOCATION, FOREGROUND_SERVICE, FOREGROUND_SERVICE_LOCATION, POST_NOTIFICATIONS, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, RECEIVE_BOOT_COMPLETED, ACCESS_NETWORK_STATE).
You don't need to touch your AndroidManifest.xml for the SDK to work. You may want to override the FGS notification icon and copy — defaults are the host app's launcher icon (silhouetted by Android) + the generic system-optimisation copy; pass a custom icon and channel via setNotificationConfig({...}) if you want branding.
ACTIVITY_RECOGNITIONis host-opt-in since 2.0.0. The SDK no longer auto-declares it (avoids Play's Health Apps classification by default). Hosts that wantLocationFix.activitypopulated + the AR-driven STILL hint feeding the motion FSM add these two lines to their ownAndroidManifest.xml:<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" /> <uses-permission android:name="com.google.android.gms.permission.ACTIVITY_RECOGNITION" />Hosts that don't add it: tracking, geofences, trips, battery savings all unchanged — motion FSM falls back to Significant Motion + geometric stop detection. Details in the Android SDK wiki.
Permission flow (real implementation)
Android permissions are a moving target across API levels: ACCESS_BACKGROUND_LOCATION only exists from API 29, POST_NOTIFICATIONS from API 33, ACTIVITY_RECOGNITION is a runtime grant from API 29 but implicit before that (and host-opt-in since SDK 2.0.0 — included in the returned list only when you declare it in your own manifest), and FOREGROUND_SERVICE_LOCATION is a manifest declaration on API 34+. The SDK handles all of that for you — your app asks for a single list and forwards it to react-native-permissions.
You almost certainly also want a permission-request library for the runtime grant flow on Android:
yarn add react-native-permissionsWe don't bundle a permission request helper — the ecosystem already solved it. We do expose a one-call permission read surface (getPermissionStatus) for when you're ready to start tracking.
Android — one call, no version branching
import { requestMultiple, type Permission } from 'react-native-permissions';
import MovexGeo from '@movexgeo/react-native-movex';
// 'all' = foreground location + background location + notifications
// (+ activity recognition IF the host declared it in their
// manifest — see "Android — manifest" above). Version-fenced
// + manifest-aware since SDK 2.0.0.
// 'mandatory' = foreground-only flows (trackOnce, foreground-only startTracking).
const required = await MovexGeo.getRequiredPermissions('all');
const results = await requestMultiple(required as Permission[]);
// results is a record of { 'android.permission.X': 'granted' | 'denied' | ... }That's it. The SDK decides which permissions to include for the device's Android version; the host app never branches on API level.
iOS
iOS doesn't have the same multi-permission cascade. getRequiredPermissions() returns [] on iOS. Use react-native-permissions directly:
import { Platform } from 'react-native';
import { PERMISSIONS, request } from 'react-native-permissions';
if (Platform.OS === 'ios') {
await request(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);
await request(PERMISSIONS.IOS.LOCATION_ALWAYS); // only if background needed
}Verify before starting tracking
Before calling startTracking, confirm permissions are in place using the SDK's one-call read API:
const perms = await MovexGeo.getPermissionStatus();
if (!perms.foregroundLocation) return showOnboardingRetry();
if (!perms.backgroundLocation) return showSettingsExplainer();
await MovexGeo.startTracking('responsive');getPermissionStatus() is cheap, read-only, and does not require initialize() — safe to call from a permission-gating screen.
Background-location prompt on Android 11+
ACCESS_BACKGROUND_LOCATION on API 30+ can only be granted from system Settings — Android doesn't allow an in-app dialog for it. react-native-permissions correctly routes the user there. After they return, re-poll getPermissionStatus() to check what they chose. Walk the user through it once at first-run, then surface the deeplink again from a "trouble tracking?" screen.
Battery optimization & OEM autostart
This is where most "tracking stopped" bug reports come from on Android. AOSP's Doze and OEM-specific autostart killers (MIUI, ColorOS, EMUI, FuntouchOS, OneUI, OxygenOS, ASUS) reap foreground services aggressively unless the user has whitelisted the app.
The SDK ships four helpers — these never change device state by themselves; they only navigate the user to the right system screen.
// AOSP Doze whitelist — every Android phone since API 23.
const ignored = await MovexGeo.isIgnoringBatteryOptimizations();
if (!ignored) {
await MovexGeo.requestIgnoreBatteryOptimizations();
// The user sees a system prompt; you can't read their answer from here,
// so re-poll isIgnoringBatteryOptimizations() after the prompt returns.
}
// OEM-specific autostart / background-protection screens.
const hasOem = await MovexGeo.hasOemBatterySettings();
if (hasOem) {
// We detect the manufacturer (Build.MANUFACTURER + Build.BRAND) and
// deep-link to the matching OEM activity. Falls back to AOSP battery
// settings → app details page if the OEM screen is missing.
await MovexGeo.openOemBatterySettings();
}You cannot programmatically tell whether the user actually toggled the OEM setting — Android doesn't expose that state. The pragmatic flow is to walk the user through it once at first-run, then surface the deeplink again from a "trouble tracking?" / support screen.
All four methods are no-ops on iOS (Apple manages background tasks centrally; there's nothing to configure).
Diagnostics — one call, everything that matters
getDiagnostics() aggregates every state that determines whether background tracking will actually work into one object:
const d = await MovexGeo.getDiagnostics(); // requires initialize()
// permissions
if (!d.permissions.background_location) {
showRequestBgPermissionUi();
}
if (!d.permissions.notification) {
showRequestNotificationPermissionUi();
}
// battery / OEM
if (!d.battery_optimizations_ignored) {
await MovexGeo.requestIgnoreBatteryOptimizations();
}
if (d.has_oem_battery_settings) {
showOemAutostartButton();
}
// location backend
if (d.location_provider === 'AOSP') {
// De-Googled / Huawei without HMS — limited accuracy is expected.
showDegoogledRomBanner();
}
// queued data
if (d.buffered_fix_count > 100) {
showSyncWaitingBanner(d.buffered_fix_count);
}
// support tickets — paste this into your bug report
console.log('support paste:', JSON.stringify(d));Full shape (snake_case to match the rest of the bridge's JSON shape — ProjectInfo, LocationFix, etc.):
interface Diagnostics {
permissions: {
foreground_location: boolean;
background_location: boolean;
notification: boolean;
activity_recognition: boolean;
sdk_int: number;
};
battery_optimizations_ignored: boolean; // Doze whitelist
has_oem_battery_settings: boolean; // MIUI / ColorOS / EMUI / etc
location_provider: 'GMS' | 'HMS' | 'AOSP' | 'CoreLocation' | null;
gms_available: boolean;
hms_available: boolean;
autostart_on_boot: boolean;
buffered_fix_count: number;
manufacturer: string;
model: string;
sdk_version: string;
}On iOS every field returns a sensible stub since most have no Apple analogue (no Doze, no OEM autostart, no GMS/HMS).
Tracking presets
startTracking(preset) accepts one of four presets. Pick the one that matches your delivery / fleet / consumer-app shape:
| Preset | When to use | Cadence | Foreground service |
|---|---|---|---|
| 'efficient' | Background-quiet apps that only need to catch geofence events (delivery confirmations, store visits). OS-level geofencing wakes the app cheaply. | ~3 min / 100 m | No |
| 'responsive' (default) | Most fleet / delivery apps. Motion-gated; cadence ramps with detected speed. | 30 s–5 min on motion, silent at rest | Yes |
| 'adaptive' | Apps that need a regular firehose for analytics, OR-gated by distance + time. | 10 m or 5 s, whichever fires first | Yes |
| 'continuous' | Driver telematics, asset tracking — every fix matters. | ~1 s | Yes (required) |
The motion state machine is on by default (Android, since 0.1.4). At rest, GPS is powered off entirely; Activity Recognition + TYPE_SIGNIFICANT_MOTION + a geofence around the last fix detect motion resumption and re-arm GPS. This drops the fix rate at rest by ~10× without missing trips.
To disable the FSM (for apps that need a true parked heartbeat):
await MovexGeo.setMotionStateMachineEnabled(false);
// Now efficient/responsive emit at preset cadence regardless of motion.To tune the FSM:
await MovexGeo.setMotionConfig({
stopDetectionDistance: 75, // meters
stopDetectionFixCount: 4, // consecutive within-radius fixes → stop
stopTimeoutSec: 300, // PendingStop → Stationary delay
motionTriggerDelaySec: 30, // PendingStart → Moving delay
disableStopDetection: false,
});Source of truth for defaults: sdk/android/sdk/src/main/kotlin/movex/geo/MotionConfig.kt.
Events
Geofence transitions, motion changes, and other server-side notifications stream through a single onEvents subscription:
const sub = MovexGeo.onEvents(events => {
for (const e of events) {
switch (e.event_type) {
case 'user.entered_geofence':
console.log('entered fence:', e.geofence_id);
break;
case 'user.exited_geofence':
console.log('exited fence:', e.geofence_id);
break;
// see types.ts for the full vocabulary
}
}
});
// later
sub.remove();Events come from a 5-second long-poll of GET /v1/events on the server. Foreground geofence events on Android also come from the OS geofencing client (sub-second).
LOCAL-tier projects don't generate server events — geofences are evaluated entirely on-device and surfaced through the same onEvents callback.
Offline buffer
When the device loses network mid-trip, fixes queue in an on-device ring buffer (10k cap, oldest evicted). They drain automatically when connectivity returns.
const pending = await MovexGeo.bufferedCount();
if (pending > 0) {
const drained = await MovexGeo.drainBuffer();
console.log('drained', drained, 'pending', await MovexGeo.bufferedCount());
}You usually don't need to call drainBuffer() manually — the SDK runs a ConnectivityManager.registerNetworkCallback (Android) / NWPathMonitor (iOS) drain on every reconnect. Use it only when you want to force a drain (e.g. user pressed "Sync now").
Error handling
All async APIs reject with a typed code so you can branch:
import type { MovexGeoErrorPayload } from '@movexgeo/react-native-movex';
try {
await MovexGeo.verify();
} catch (e) {
const err = e as MovexGeoErrorPayload;
switch (err.code) {
case 'invalid_key': return showOnboardingError('Publishable key rejected.');
case 'key_revoked': return showOnboardingError('Key revoked. Get a new one.');
case 'project_suspended': return showOnboardingError('Project suspended. Contact us.');
case 'permission_denied': return showPermissionUi();
case 'location_timeout': return retryCapture();
case 'network_error': return retryLater();
default: return reportToSentry(err);
}
}The vocabulary matches the backend's 4xx envelope — branchy error handling works regardless of which platform raised it.
API reference
| TS API | Key required | Description |
|---|---|---|
| captureCurrentPosition(options?) | No | One-shot device-side location capture. No initialize() needed — just location permission. Does not post to server. |
| getPermissionStatus() | No | One-call snapshot of SDK-relevant permissions. Safe to call before initialize(). |
| getRequiredPermissions(scope?) | No | Permission strings to pass to react-native-permissions for 'mandatory' or 'all' scope, version-fenced + manifest-aware. |
| initialize(publishableKey, endpoints?) | Yes | Sync; sets up the singleton. No network. |
| setUserId(userId \| null) | Yes | Associate fixes with an external user id. |
| getDeviceId() | Yes | Stable per-install ID (dev_<32 hex>). |
| verify() | Yes | GET /v1/me — pulls project info + tier. |
| trackOnce(lat, lng, accuracyM?) | Yes | POST /v1/track — one-shot fix posted to server. |
| startTracking(preset?) | Yes | Start the FGS / Core Location loop. |
| stopTracking() | Yes | Stop tracking; FGS is removed. |
| onEvents(handler) | Yes | Subscribe to geofence / motion / server events. |
| bufferedCount() | Yes | Fixes queued in the offline buffer. |
| drainBuffer() | Yes | Force a flush of the offline buffer. |
| close() | Yes | Tear down the SDK (rare; usually leave running). |
| setNotificationConfig({...}) | Yes | Override the FGS notification (Android). |
| setMotionConfig({...}) | Yes | Tune the motion state machine (Android). |
| setMotionStateMachineEnabled(boolean) | Yes | Disable the FSM (returns to preset-cadence tracking). |
| setAdaptiveConfig(distanceMeters, intervalMs) | Yes | Tune 'adaptive' preset's OR gate. |
| setAutostartOnBoot(boolean) | Yes | Respawn the FGS on device reboot (off by default). |
| setDevMode(boolean) | Yes | Verbose logging + dev-only relaxations. |
| isIgnoringBatteryOptimizations() | Yes | Is host exempt from Doze? iOS: always true. |
| requestIgnoreBatteryOptimizations() | Yes | Show the Doze whitelist prompt. iOS: no-op. |
| hasOemBatterySettings() | Yes | Is an OEM autostart screen reachable on this device? |
| openOemBatterySettings() | Yes | Open the OEM autostart screen. |
| getDiagnostics() | Yes | Full SDK + device diagnostic snapshot. Requires initialize(). |
Full TypeScript types live in src/types.ts. The native API is mirrored 1:1 — Android impls are in sdk/android/, iOS in sdk/ios/.
Troubleshooting
"My app is force-stopped after 10 minutes in the background."
That's an OEM battery saver. Walk the user through openOemBatterySettings(). On stock Android, ensure isIgnoringBatteryOptimizations() is true. Check getDiagnostics().locationProvider — if it's 'AOSP', you're on a de-Googled ROM where background reliability is fundamentally weaker.
"Tracking works in the foreground but not background."
Check getDiagnostics().permissions.backgroundLocation is true. On Android 11+, the OS routes the request to system Settings — there's no in-app dialog the user can tap through.
"startTracking('responsive') succeeds but I get one fix and then nothing for 20 minutes."
The motion state machine entered Stationary. Move the device (or call setMotionStateMachineEnabled(false)). The FSM is on by default since 0.1.4; opt-out via setMotionStateMachineEnabled(false) if you need a true parked heartbeat.
"No notification appears on Android 13+."
POST_NOTIFICATIONS wasn't granted. The foreground service still runs; it just renders invisibly. Request the permission with react-native-permissions, then call startTracking again.
"getDiagnostics() throws IllegalStateException."
You called it before MovexGeo.initialize(...). Initialize first. For a pre-init permission check, use getPermissionStatus() instead — it doesn't require initialization.
"Events stopped arriving."
For LOCAL tier, only on-device fence transitions fire onEvents. For PAID / SHARED tiers, events come from a 5 s server long-poll — confirm the device is online (getDiagnostics().bufferedFixCount will climb if offline) and that the project's API key still resolves (verify()).
Public API parity
Every TS method maps 1:1 to the native singletons. Useful if you're reading the platform SDK source.
| TS API | Android (MovexGeo object) | iOS (MovexGeo.shared) |
|---|---|---|
| captureCurrentPosition(options?) | MovexGeo.captureCurrentPosition(context, options) (suspend) | real impl via CLLocationManager.requestLocation() |
| getPermissionStatus() | Permissions.snapshot(...) | real impl via CLAuthorizationStatus + UNUserNotificationCenter + CMMotionActivityManager |
| getRequiredPermissions(scope?) | Permissions.required(scope, sdkInt) | [] — iOS uses one-shot LOCATION_WHEN_IN_USE / LOCATION_ALWAYS prompts, no cascade |
| initialize(...) | initialize(...) | initialize(...) |
| setUserId(...) | setUserId(...) | setUserId(...) |
| getDeviceId() | deviceId | deviceId |
| verify() | verify() (suspend) | verify() (async) |
| trackOnce(...) | trackOnce(...) | trackOnce(...) |
| startTracking(...) | startTracking(...) | startTracking(...) |
| stopTracking() | stopTracking() | stopTracking() |
| onEvents(...) | onEvents { ... } | onEvents { ... } |
| bufferedCount() | bufferedCount | bufferedCount (async) |
| drainBuffer() | drainBuffer() | drainBuffer() |
| close() | close() | close() |
| isIgnoringBatteryOptimizations() | BatteryOptimization.isIgnoringBatteryOptimizations(...) | stub (true) — no Doze on iOS |
| requestIgnoreBatteryOptimizations() | BatteryOptimization.requestIgnoreBatteryOptimizations(...) | stub (false) — no analogue on iOS |
| hasOemBatterySettings() | BatteryOptimization.hasOemSettings(...) | stub (false) — no OEM autostart on iOS |
| openOemBatterySettings() | BatteryOptimization.openOemSettings(...) | stub (false) — no analogue on iOS |
| getDiagnostics() | MovexGeo.diagnose() (suspend) | real impl, Apple defaults for non-applicable fields |
License
Apache 2.0 — see LICENSE. Copyright 2026 moveXgeo.
Contributing
The bridge lives in the movex-geo monorepo. See sdk/react-native/CONTRIBUTING.md for dev workflow, building against local native SDKs, and Codegen.
