@andynursa/checkpoint-react-native
v0.1.0
Published
Checkpoint location / geofence / ingest SDK for React Native (iOS + Android). A thin bridge over the @andynursa/checkpoint-capacitor native cores; all detection logic runs server-side.
Maintainers
Readme
@checkpoint/react-native
A thin React Native bridge over the two device-verified Checkpoint native
cores (@checkpoint/capacitor's iOS GeofenceManager + Android Play Services /
foreground-service layer). It re-implements no geofencing logic — that's
server-side. The wrapper's only jobs are (a) expose the native API in JS/TS and
(b) wire the platform permissions / background modes / manifest entries the cores
require. This mirrors how HyperTrack ships its RN SDK over its native cores.
JS → Checkpoint.init({ publishableKey })
NativeGeofence.configure({ … }); NativeGeofence.addFence({ … })
│
▼ (native module — same name "CheckpointGeofence" on both platforms)
iOS GeofenceManager.shared ── or ── Android GeofenceStore + Play Services
│ │
▼ ▼
region wake → POST /v1/ingest from URLSession / OkHttp (NEVER a JS fetch)Status — not published. This package and the native core it binds to (
@checkpoint/capacitor) both live on unmerged branches. The npm names and the native dependency coordinates below are intended names. See Dependency on the native core.
Architecture: classic bridge, hosted on New Arch
Both native modules are classic bridge modules (iOS RCTEventEmitter, Android
ReactContextBaseJavaModule) rather than Swift/Kotlin TurboModules.
- A TurboModule codegen spec (
src/NativeCheckpointGeofence.ts) IS shipped, so the JS resolves the module throughTurboModuleRegistryon the New Architecture and throughNativeModuleson the classic one — same registry name ("CheckpointGeofence"), one resolution path. - The native implementations are classic-bridge because RN's interop layer hosts a legacy module unchanged under bridgeless mode, so one Swift file + one Java file serve both architectures. Every method is a trivial forward to the engine; there is no synchronous per-frame hot path that would justify the extra Objective-C++ / JNI codegen shim a fully-native TurboModule needs.
Install (intended)
yarn add @checkpoint/react-native
cd ios && pod installAutolinking (react-native.config.js + the RN gradle/CocoaPods plugins) wires the
iOS pod and the Android package — no MainApplication or Podfile edits.
Usage (mirrors the universal SDK contract)
import { Checkpoint, NativeGeofence } from "@checkpoint/react-native";
// 1. Bootstrap the transport. baseUrl + anonKey are REQUIRED (no baked defaults —
// a published SDK must not ship a platform ref). publishableKey is safe in a binary.
Checkpoint.init({
publishableKey: "pk_live_…",
baseUrl: "https://<project>.supabase.co",
anonKey: "<anon>",
});
// 2. Persist creds + subject natively so a BACKGROUND relaunch can POST without JS.
await NativeGeofence.configure({
baseUrl: "https://<project>.supabase.co",
anonKey: "<anon>",
publishableKey: "pk_live_…",
subjectExternalId: "nurse-123", // YOUR id for the subject
trackingMode: "geofence", // geofence (default) | always | off
});
// 3. Permissions (see the platform ladder below).
await NativeGeofence.requestAlwaysAuthorization();
await NativeGeofence.requestNotificationAuthorization();
await NativeGeofence.requestBatteryExemption(); // Android only; no-op on iOS
// 4. Register the perimeter ring → native OS geofence (wakes a force-quit app).
await NativeGeofence.addFence({
id: "facility-1", latitude: 40.0, longitude: -111.0, radius: 200,
});
// 5. React to crossings while JS is alive (the native layer already POSTed the wake
// ping regardless). Detection — arrivals/exits/dwell — is server-side.
const sub = await NativeGeofence.addListener("regionEvent", (e) => {
console.log(e.type, e.regionId, e.latitude, e.longitude, e.timestamp);
});
// later: sub.remove();
// Tracking mode via the ergonomic facade (identical to @checkpoint/capacitor):
await Checkpoint.setTrackingMode("always");
const { mode, streaming } = await Checkpoint.getTrackingMode();The public surface (Checkpoint, NativeGeofence, TrackingMode, RegionEvent,
NativeDiagnostics) is byte-identical to @checkpoint/capacitor — that
cross-wrapper uniformity is the whole point.
Cross-wrapper listener idiom
The types and wire values are uniform across all four wrappers; the call syntax for subscribing to region events is idiomatic per platform (this is the one place the "uniform API" claim is scoped to types, not literal call syntax):
| Wrapper | Subscribe | Unsubscribe |
|---|---|---|
| Capacitor | addListener('regionEvent', cb) → Promise<handle> | handle.remove() |
| React Native | addListener('regionEvent', cb) → Promise<{ remove }> | sub.remove() |
| Expo | addListener('regionEvent', cb) (re-exports RN) | sub.remove() |
| Flutter | addRegionEventListener(cb) → CheckpointListenerHandle | await handle.remove() |
| .NET MAUI | RegionEvent += handler; (C# event) | RegionEvent -= handler; |
The event name ("regionEvent"), payload (RegionEvent), and TrackingMode wire
values are identical everywhere. The shared conformance fixture
(test/conformance.spec.ts, mirrored in each wrapper) asserts that.
Platform configuration
iOS — Info.plist
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to confirm arrival at your shift facility.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Background location lets us record arrival/departure even when the app is closed.</string>
<key>UIBackgroundModes</key>
<array><string>location</string></array>Android — permission ladder (driven from JS via PermissionsAndroid)
The library manifest declares the permissions + the core's receivers/services (folded in by manifest-merge from the core AAR). The runtime grant order is your app's responsibility and must be staged — Android 10+ forbids bundling background with foreground:
ACCESS_FINE_LOCATION(foreground) —PermissionsAndroid.request(...).- Then
ACCESS_BACKGROUND_LOCATION("Allow all the time") — a SEPARATE request after foreground is granted. A geofence armed foreground-only never wakes a killed app. POST_NOTIFICATIONS(API 33+).- Call
NativeGeofence.requestAlwaysAuthorization()after the background grant so the module re-registers fences for the killed-app path. NativeGeofence.requestBatteryExemption()on aggressive OEMs (Samsung One UI especially) — an optimized app is force-stopped and its receivers disabled.
See the Checkpoint docs: docs/guides/whitelisting.md,
docs/guides/store-submission.md, docs/guides/mock-locations.md.
Dependency on the native core
This wrapper binds to the same native cores as @checkpoint/capacitor:
- iOS —
ios/CheckpointGeofence.swiftdoesimport CheckpointCapacitorand drivesGeofenceManager.shared. Intended pod dependency:CheckpointCapacitor. - Android —
CheckpointGeofenceModule.javacallscom.checkpoint.capacitor.GeofenceStore/ContinuousLocationService/GeofencingClient. Intended Maven dependency:com.checkpoint:checkpoint-android-core.
Neither coordinate is published yet. The core lives in @checkpoint/capacitor on
the unmerged but device-verified branch feat/sdk-extraction-capacitor (an
iOS Pod + an Android Capacitor module project, not yet standalone artifacts). Until
they're published, an integrator vendors the core via local path:
# ios/Podfile
pod 'CheckpointCapacitor', :path => '../node_modules/@checkpoint/capacitor'// settings.gradle — include the core module
include ':checkpoint-capacitor'
project(':checkpoint-capacitor').projectDir =
new File('../node_modules/@checkpoint/capacitor/android')Core access-control prerequisite (a real, tracked gap)
Binding from a separate module requires two mechanical, no-logic changes to the core (they do not alter behavior):
- iOS:
GeofenceManager's engine methods (configure,addFence,clearFences,requestAlwaysAuthorization,requestNotificationAuthorization,applyTrackingMode,currentMode,isStreaming,monitoredCount,diagnostics,authorizationStatusString, theonRegionEventcallback) and theTrackingModeenum are currentlyinternal. They must be widened topublic(onlyshared+reviveForBackgroundLaunchare public today). - Android:
GeofenceStore's methods and the services'start/stop/RUNNINGare package-private (com.checkpoint.capacitor). They must be widened topublicor this module must move into that package.
Known gaps / uncertainties
- Android
regionEventis native-only in the current core. The Android broadcast receiver POSTs + fires a notification but does not emit a JS event; the Capacitor core has nonotifyListeners/RCTDeviceEventEmitteron that path. SoaddListener("regionEvent")is wired for contract symmetry but only fires on Android once the core adds a JS-bridge broadcast. iOS emits via the engine'sonRegionEventand is live. (This is a faithful mirror of the core's current behavior, not a wrapper bug.) - The device REST hot-path is re-exported, not duplicated.
mintDeviceToken,getTrackingDirective, andsetDeviceTrackingModeare framework-agnostic pure TypeScript in@checkpoint/capacitor'ssrc/api.ts. This wrapper re-exports the real implementation (export { … } from "@checkpoint/capacitor") rather than forking it, so the wire contract (3-key directive RPC body, anon-key bearer on the RPCs, publishable-key bearer on mint) is byte-identical to the core.Checkpoint.initdelegates to the core's transport so those re-exports read the same creds.@checkpoint/capacitoris an unpublished peer (branchfeat/sdk-extraction-capacitor);src/checkpoint-capacitor-shim.d.tsis an ambient declaration so this package typechecks standalone, and it resolves to the real module once the core is published. (Like the Expo wrapper'sreact-nativeshim, this ambientdeclare moduleis a pre-publish stand-in only.) - Not device-verified. Only TypeScript typechecks here. The native cores are device-verified; this thin bridge over them is not yet — see the device checklist.
- Expo: use a development build (config plugin TBD — the Expo wrapper is a separate package). This will not run in Expo Go.
