1Month Portfolio map.ping
Technical Review

map.ping On-Device Proximity Engine

A private, offline proximity-alert app. Import any Google My Map, then get a local notification when you come near a saved pin - even with the app closed and the phone locked. No backend, no accounts, no telemetry: a motion-aware background-geolocation engine, a 7-gate notification pipeline, and Hermes-safe KML import, all running on the device.

0 Backend Services
7 Notification Gates
5 Languages
47 Passing Tests
📍

Product Overview

map.ping turns any Google My Map into location-aware reminders. Import a map of places you care about - favourite restaurants, viewpoints, a scavenger-hunt route, store branches - and the app quietly watches your location in the background. Come within your chosen radius of a pin and it taps you on the shoulder with a local notification.

"Import a map. Go about your day. Get the ping - even with the app closed or the phone locked."
🔒
Private by Design

No account, no servers, no analytics. Maps and location stay on the phone.

🔋
Easy on the Battery

Motion-aware scanning: GPS wakes when you move, sleeps when you stop.

🌙
Works in the Background

Alerts fire while the app is closed or locked. Quiet hours silence the night.

🗺️
Bring Your Own Maps

Any Google My Map by file, public link, or your own private maps via Drive.

🎚️
Yours to Tune

Pick the alert radius, snooze a map, switch metric/imperial, read it in 5 languages.

📲
Share to Import

iOS Share Extension - send a My Maps link or .kml straight into the app.

⚙️

Tech Stack

Layer Technology Version / Notes
Mobile App React Native + Expo Expo SDK 54, RN 0.81
Runtime Hermes + New Architecture (Fabric / TurboModules) newArchEnabled
Language TypeScript (100%) v5.9, tsc --noEmit clean
Background Engine react-native-background-geolocation v5.1.1 (New-Arch, nested config)
Background Tasks react-native-background-fetch + Android headless v4.4
Maps react-native-maps (plain MapView) v1.20
Local Storage AsyncStorage + expo-secure-store device-only, no DB server
Map Parsing fflate + fast-xml-parser (KML / KMZ) Hermes-safe
Notifications expo-notifications (time-sensitive, local) ~0.32
Monetization RevenueCat (react-native-purchases) v10, fails safe to Free
i18n i18next + react-i18next 5 languages, RTL
OAuth (Pro) expo-auth-session + Google Picker drive.file private import
Build & Distribution EAS Build → App Store / Google Play remote appVersionSource
Marketing Site Vite + React + Tailwind + shadcn/ui Firebase Hosting
🏗️

Architecture

No Backend, By Design

map.ping has zero servers. Everything - import, storage, scanning, and notifications - happens on the device. That is the architecture: the privacy guarantee and the operating cost ($0) both fall out of the same decision. The only network calls the app ever makes are an optional fetch of a public Google My Map's KML and, for Pro users, an OAuth round-trip to read their own Drive maps.

map.ping/
├── src/
│   ├── background/      # Engine: geolocation.ts, scanRunner.ts,
│   │                    #   headlessTask.ts, BackgroundScanController.tsx
│   ├── import/          # kmlParse.ts (pure), kmlImport.ts (file/URL/Drive IO)
│   ├── utils/           # backgroundTaskLogic.ts (gates), geo, proximity,
│   │                    #   scanLogic, quietHours, notificationSetup, secureStorage
│   ├── storage/         # mapStore.ts — AsyncStorage CRUD + snooze
│   ├── context/         # DataContext, SettingsContext (local state)
│   ├── monetization/    # RevenueCat entitlements (Free / Pro)
│   ├── screens/         # Maps, MapView, ImportMap, Settings, Paywall, Onboarding
│   ├── components/      # MapCard, ErrorBoundary, Toast, modals
│   ├── share/           # iOS Share Extension sheet
│   └── i18n/            # en / he / fr / ru / ar (strict-typed)
├── index.ts            # registers the Android headless task
├── index.share.js      # Share Extension entry
└── app.config.js       # dev/prod variants, plugins, privacy manifest

On-Device Data Flow

Map Sources
📄 .kml / .kmz File
🔗 Public My Maps Link
☁️ Google Drive (Pro)
📲 iOS Share Extension
Import Pipeline (on-device)
🧩 KML / KMZ Parser
fflate + fast-xml-parser
Local Storage
🗂️ AsyncStorage
maps + pins
🔐 SecureStore
background_maps bridge
Background Proximity Engine
🛰️ bg-geolocation
motion-aware GPS
🔁 scanRunner
per-fix scan
🚦 Gate Pipeline
Output
🔔 Local Notification
deep-links to the pin
Two JS contexts, one engine. The same runScan() executes from the foreground onLocation handler and from the Android headless task (which runs with the app killed, in a separate JS VM). Because the headless context has no React state, the scanner reads pins and settings straight from storage - which is why a SecureStore background_maps bridge exists.
🛰️

Proximity Engine

Motion-Aware Background Geolocation

The GPS spine is react-native-background-geolocation v5, configured for the New Architecture with the nested { geolocation, app, logger } shape. Its defining trait is motion awareness: the OS hands the engine a fix only while the device is actually moving, then lets it idle when you stop - so the app can run all day without draining the battery. Every fix, foreground or headless, funnels into one self-contained runScan().

Smart Driving - Adaptive GPS Tiers

Walking and driving need very different GPS cadence. A hysteresis state machine (ported from the parent Choers engine, kept as pure, tested functions) promotes and demotes through tiers based on sustained speed readings inside a time window - never on a single noisy sample:

Tier Trigger GPS Profile Min Refresh
Pedestrian Default / slow High accuracy, Fitness, 20 m interval < 250 m
City ≥ 15 km/h sustained High accuracy, Fitness, 100 m interval 500 m
Commuter ≥ 60 km/h sustained High accuracy, Automotive, deferred 100 m 2000 m
Self-healing config. Each tier profile is validated and merged field-by-field over a hard-coded default, so a missing or out-of-range value can never wedge startLocationUpdates. Reverts are time-based with hysteresis - drift below a revert threshold for the configured timeout before stepping down a tier.
🔔

Notification Gate Pipeline

7 Gates Between a GPS Fix and a Ping

The hard problem in a proximity app isn't detecting a nearby pin - it's not nagging you. Every fix runs a deterministic gate chain; the notification only fires if all gates pass. The gate logic lives in pure, unit-tested functions.

Candidate Gates
  • Gate 0 - Accuracy: skip if GPS uncertainty > search radius (drift guard)
  • Gate 1 - Active pins: no active maps/pins → stop (snoozed maps count as inactive)
  • Gate 2 - In range: no pins inside the radius → stop
Geometry via Haversine (computeNearby)
Anti-Nag Gates
  • Gate 3 - Spatial cooldown: haven't moved minRefreshDistance since last alert
  • Gate 4 - Time dedup: same pin-set fingerprint within a 5-min window
  • Gate 5 - Paused: user paused notifications
  • Gate 6 - Quiet hours: inside the configured do-not-disturb window
Fingerprint = sorted set of matched pin ids
Notify
  • Nearest-first title + distance in the body
  • timeSensitive interruption level
  • Stable id auto-replaces the previous ping
  • Payload { mapId, pinId } deep-links the tap to the pin
Persists fingerprint + lat/lng/time for next pass
🗺️

Map Import Pipeline

Three Sources, One Pure Parser

File, public link, and Drive imports all converge on a single pure parser (parseKmlBytes / parseKmlString) that handles both raw KML and zipped KMZ. Keeping the parser pure is what makes the import logic unit-testable with no device.

📄
File Import

Pick a .kml/.kmz via the document picker; validated by extension (MIME is unreliable from Files/Drive).

🔗
Public Link

Extracts the My Maps mid from any viewer/edit/share URL, then fetches maps/d/kml?mid=…&forcekml=1. The mid becomes the map id so a re-import dedups in place.

☁️
Google Drive (Pro)

OAuth + Picker over drive.file; exports the picked My Map to KML with a raw-media fallback for maps that don't export.

Hermes-safe by constraint. Import stays on fflate + fast-xml-parser deliberately - heavier libs (jszip, adm-zip, sanitize-html) misbehave under Hermes/New-Arch. Friendly, actionable errors steer the user back to a working path (e.g. "this map isn't public - set it to 'Anyone with the link', or import it as a file instead").
🔒

Data & Privacy

Privacy isn't a setting in map.ping - it's the architecture. There is no account system and no backend to send data to, so there is nothing to leak. Location is used for exactly one thing: deciding whether to show you a local alert.

🚫
No Accounts, No Telemetry

Zero sign-up, zero servers, zero analytics SDKs. The app never phones home.

📵
Location Not Linked, Not Tracked

The iOS privacy manifest declares precise location as App Functionality only - not linked to identity, not used for tracking.

🔐
On-Device Only

Maps and pins live in AsyncStorage; the background bridge in encrypted SecureStore. Nothing uploaded.

📜
Honest Permission Strings

NSMotionUsageDescription for battery-saving motion detection; Face ID usage string removed since the app uses no biometrics.

💳

Monetization

Free / Pro Tiers

Subscriptions run through RevenueCat, which gates the Pro capabilities below behind an active subscription. If the RevenueCat keys are absent, the app fails safe to Free rather than unlocking Pro by accident.

Capability Free Pro
Imported maps Up to 3 More Maps
Simultaneously active maps 1 (radio behaviour) All Maps
Import sources File + public link + Google Drive private maps
📲

Platform Integration

📤
iOS Share Extension

A second iOS target renders its own RN sheet, imports in-process, and hands the finished map to the app through an App Group - no host-app reopen, so consecutive shares just work. Heavy native modules are excluded to keep the extension bundle small.

🔗
Deep Linking

Custom scheme mapping://; a notification tap routes straight to the matched pin on the map via the { mapId, pinId } payload.

🧭
Turn-by-Turn Hand-off

From a focused pin, "Directions" opens the platform maps app with the destination pre-filled.

🤖
Android Headless + Foreground Service

A registered headless task runs scans with the app killed; foreground-service and background-location permissions are injected by the config plugin.

⏱️
Time-Sensitive Notifications

The time-sensitive entitlement lets proximity pings break through Focus when it matters.

🌐
5 Languages + RTL

en / he / fr / ru / ar via i18next, strict-typed against en.json; switching to Hebrew or Arabic flips layout direction via an expo-updates reload.

🧪

Quality & Tooling

Testing Strategy

The riskiest logic - proximity geometry, the notification gates, KML parsing, and the Free/Pro rules - is deliberately extracted into pure functions with no React, RN, or Expo imports, so it runs in a plain ts-jest node environment with no simulator. That's what makes the engine's decisions deterministic and cheap to verify.

Suite Covers Type
proximity.test.ts computeNearby distance / radius matching Pure
kmlParse.test.ts KML/KMZ parsing, placemark extraction Pure
Tier rules Free/Pro tier caps & reconciliation Pure
Total 47 tests 3 suites, all green

Tooling & Resilience

Type-Safe

tsc --noEmit is clean; strict TypeScript across the whole app and i18n keys.

🩹
patch-package

Native-module quirks pinned with patches applied on postinstall for reproducible builds.

🧯
Error Boundary

A top-level ErrorBoundary keeps a single render failure from taking down the app.

🔇
Stripped Logs

babel-plugin-transform-remove-console strips console.* from release builds.

💡

Key Architectural Decisions

No Backend at All

On-device everything makes privacy a property of the architecture and drops operating cost to zero - the central design choice the rest of the app follows from.

bg-geolocation v5 (public)

Shipped on the public npm package with nested New-Arch config; the iOS license validates only on release builds and lives in Info.plist (the plugin's license prop is Android-only).

Plain MapView, Not Clustering

The clustering wrapper crashes on Fabric when pins actually cluster, so the map renders plain markers. If clustering returns, it'll be the JSI-based clusterer, not the dead lib.

Pure Logic, Side-Effect Edges

Gates, geometry, and parsing are pure and tested; only the thin outer layer touches Expo APIs - the pattern that keeps the headless path trustworthy.

Hermes-Safe Imports

fflate + fast-xml-parser only - chosen because heavier KML/zip libs break under Hermes and the New Architecture.

Fails Safe to Free

Missing RevenueCat or Google keys degrade gracefully - the app runs Free-only rather than crashing or unlocking Pro by accident.

Try map.ping

Live on the App Store · in alpha on Google Play

App Store Google Play (Alpha) 🌐 Visit Site