Skip to main content

Mobile app

apps/mobile is an Expo SDK 55 / React Native 0.83 app bootstrapped with Ignite 11 and then reshaped for Hearth.

Running it​

The app depends on native modules (MapLibre, background location, secure store), so Expo Go will not work. You need a development build.

pnpm install # from the repo root
cd apps/mobile
npx expo prebuild # generates ios/ and android/ (git-ignored)
npx expo run:ios # simulator, needs Xcode
npx expo run:android # emulator or device, needs Android Studio / SDK

After that, pnpm start (which runs expo start --dev-client) is enough for JavaScript-only changes.

Point the app at a server on first launch. For a simulator talking to a server on the same machine, that's http://localhost:4000 on iOS or http://10.0.2.2:4000 on the Android emulator. Set REGISTRATION_MODE=open on the server while you play.

Builds​

Two environments, development and production, plus a variant of each for the shape of artifact you need. Every command builds locally rather than on Expo's servers.

pnpm build:android:dev # debug APK for an emulator or a plugged-in phone
pnpm build:android:prod # AAB, what Play Store wants
pnpm build:android:prod:apk # APK, for sideloading a release build
pnpm build:ios:sim # debug build for the simulator
pnpm build:ios:dev # debug build for a real device
pnpm build:ios:prod # IPA

An EAS development build allows plain HTTP to any host so a family can try the app against a LAN server. A release build doesn't. Android refuses plain HTTP outright and iOS allows it only to local network addresses. See Plain-HTTP servers on a LAN. That's the behaviour you want, and the thing to remember when a release build cannot reach a server that a development build could.

Android release builds are signed with the keystore EAS holds for the project. EAS injects it into build.gradle after the config plugins run, so nothing in the repository or in your shell can swap the key. That key must never change. Android refuses an update signed with a different one, so a build signed by anything else strands everyone who already installed the app. Keep a copy outside the repository, for example under ~/.hearth/keystores/. Run npx eas-cli@latest credentials -p android, pick the production profile, then Keystore and Download to get it.

plugins/withAndroidReleaseSigning.ts only matters for a build made outside EAS, with expo prebuild and Gradle directly. There it swaps the template's debug key for whatever the HEARTH_UPLOAD_* Gradle properties point at, and falls back to debug signing when they are missing, which is fine for trying the app and must not be given to anyone. plugins/withAndroidReleaseManifest.ts writes a release-only manifest that removes SYSTEM_ALERT_WINDOW, which React Native asks for so the dev menu can draw over other apps.

Push only fully works in a build you make yourself, with your own Expo project, Firebase project and Apple developer account. The store builds are waiting on App Store and Play Store review and can't ship with the maintainer's personal Expo, Firebase and Apple push keys. app.json has the maintainer's Expo owner and extra.eas.projectId committed. Remove both and run eas init, which writes a project id of your own, the one PUSH_PROVIDER=expo needs. An iOS build also needs a bundle identifier on your own Apple team. Android needs your own google-services.json. It's gitignored, and app.config.ts only points at it when the file is present. google-services.example.json shows the shape. Without a project id the app tells the user push is unavailable. See push notifications for the rest.

Permissions​

Hearth asks for exactly what background family tracking needs. A checklist (Set up Hearth) shows up on first sign-in and can be revisited from You → Tracking status.

PermissioniOSAndroidWhyRequested
Location, foregroundNSLocationWhenInUseUsageDescriptionACCESS_FINE_LOCATION (+ coarse)Show you on the mapOnboarding, step 1
Location, AlwaysNSLocationAlwaysAndWhenInUseUsageDescription, UIBackgroundModes: locationACCESS_BACKGROUND_LOCATION, FOREGROUND_SERVICE_LOCATIONUpdates while the app is closed, plus arrive/leave alertsOnboarding, step 1 (second prompt)
Precise locationNSLocationDefaultAccuracyReduced = false, full vs reduced detectedfine vs coarse detectedPlaces and trips need GPS accuracyDetected, checklist links to Settings
Notificationsruntime, UIBackgroundModes: remote-notification for the silent wakePOST_NOTIFICATIONSArrivals, battery, SOS. Local SOS banners need it even with no push providerOnboarding, step 2
Battery optimisation exemptionn/aREQUEST_IGNORE_BATTERY_OPTIMIZATIONSDoze and vendor power managers are the #1 reason background location diesOnboarding (Android), opens the system dialog
Do Not Disturb accessn/aACCESS_NOTIFICATION_POLICYLets the SOS channel through Do Not Disturb. OptionalChecklist (Android), optional, opens the system list
Background refresh / tasksUIBackgroundModes: fetch, processing, BGTaskSchedulerPermittedIdentifiersRECEIVE_BOOT_COMPLETED, WAKE_LOCKFlush the offline queue when the OS allowsDeclared, checklist explains the iOS toggle
CameraNSCameraUsageDescriptionCAMERA (via plugin)Scan an invite QROnly when you tap Scan
Local networkNSLocalNetworkUsageDescription, NSBonjourServicesn/aSelf-hosted servers on your LANPrompted by iOS on first LAN connection
Motion and activityNSMotionUsageDescriptionACTIVITY_RECOGNITION (+ the Play Services variant)Let the OS say when you are driving or still, so the GPS can sleepOnboarding, right after location. Hidden on a phone that cannot classify motion
Photo libraryNSPhotoLibraryUsageDescription (picker plugin)system picker, no permissionChoose one profile pictureOnly when you tap your avatar

Android calls the background choice Allow all the time and the foreground one Allow only while using the app, so on Android the checklist uses those words where iOS says Always and While Using. The Android text opens with the sentence Play's reviewers look for in a prominent disclosure, that Hearth collects location data even when the app is closed or not in use. The strings are chosen by platform when i18n/en.ts loads, and a member's issue line, which may describe a phone on the other platform, is worded to fit both.

The motion classifier runs whenever tracking does. Core Motion and Play Services have already classified the movement for the system, so asking them costs far less than watching the phone's own position, which is what the GPS fallback does. The checklist asks once, from its own Allow button, and nothing the tracker runs in the background ever raises the dialog: Android answers a request made from a process with no Activity as denied without showing anything, and the module then reports denied for good. A phone that refuses, or one that cannot classify motion at all, works stops out from position instead. The server receives the same fixes either way.

The accelerometer and gyroscope need no permission on either platform at the rates Hearth samples them, and on iPhone the barometer comes under Motion and Fitness, which the motion classifier already asks for. That is what makes crash detection possible without asking for anything new. It appears on the setup checklist for exactly that reason: unannounced, it would be a sensor nobody agreed to.

Commercial trackers ask for a pile of things Hearth deliberately doesn't. No contacts, because invites are codes and QR. No microphone. No Bluetooth, because there is no hardware tag to talk to. No advertising identifier, and no tracking of any kind. The photo library is reached through the system picker, which hands back the one file you chose rather than access to the album. Motion is there to let the GPS sleep, never to collect a fitness feed.

The keep-alive checklist​

Android closes apps that run in the background, and Xiaomi, Huawei, OPPO, vivo, Samsung, Transsion (Infinix, Tecno, itel) and ASUS each ship a power manager of their own that closes them sooner and without asking. Nothing in the platform can read those switches back, so the checklist works from the make. vendorFor() in services/permissions.ts maps Build.MANUFACTURER onto those seven groups, and on any of them the checklist shows a row, Keep Hearth running in the background, as a Check. The one thing the OS does read back is the person setting Hearth's background usage to Restricted (ActivityManager.isBackgroundRestricted), and that turns the same row into an Off on any Android, Pixel included.

The row opens KeepAliveScreen. It says in plain words what the phone does to an app like this, lists that maker's steps as dontkillmyapp.com gives them, reads back the Restricted setting and the battery optimiser so the person can see whether what they changed took, and has one button that calls openVendorPowerManagerAsync() in hearth-motion. That walks the component names transistorsoft's DeviceSettings and the AutoStarter library use for each vendor's autostart or power screen, newest first, and falls back to Hearth's own app settings page, where every Android keeps the Battery > Unrestricted toggle. The battery optimisation row raises ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS from native code first, which Play allows a family safety app to do, and only then falls back to the intent launcher and the settings list.

Battery Saver on Android and Low Power Mode on iOS get a row of their own for as long as they are on. Both stop background location, and both flip without the app being opened. The checklist re-reads everything on focus and on every return to the foreground, because these settings revert after system updates on several of those makes.

None of the vendor component names can be checked without the handset in hand. That is why every candidate is tried in turn and the app's own page is the floor.

Plain-HTTP servers on a LAN​

The two platforms differ here, and you tend to find out at release time.

BuildiOSAndroid
EAS development profileany HTTP host (NSAllowsArbitraryLoads)any HTTP host (usesCleartextTraffic)
Anything else, a local prebuild includedprivate ranges and .local only (NSAllowsLocalNetworking)debug any HTTP host, release no HTTP
Built with HEARTH_ALLOW_HTTP=1any HTTP hostany HTTP host

Both are decided at prebuild time from EAS_BUILD_PROFILE. NODE_ENV plays no part, because expo prebuild sets it to development whenever it is unset, and a release archived from that prebuild would otherwise allow every host without anyone having asked for it. An Android debug build allows HTTP anyway, from the template's debug manifest. HEARTH_ALLOW_HTTP=0 forces the strict behaviour into an EAS development build, which is how you check a TLS-only setup before you ship it.

iOS ignores NSAllowsArbitraryLoads whenever NSAllowsLocalNetworking is also present, so Hearth emits exactly one of the two.

A production Android build therefore can't reach http://192.168.1.10:4000 unless it was built with HEARTH_ALLOW_HTTP=1, while a production iOS build can. Put TLS in front of the server (see self-hosting) and neither caveat applies.

The app can't ask the OS which of these it was built with, so app.config.ts writes the answer into extra.plainHttp, and utils/serverProbe.ts reads it back with the platform. The server screen suggests typing http:// only when that address could work from this build. Anywhere else, an Android release or a public name on an iPhone, it says the app connects only over HTTPS, and an http:// address it can't use is refused in the same words rather than as a server that didn't answer. In development extra comes from whatever Metro evaluated, which can differ from the build on the phone.

The API serves https://your-server/join/ABCD1234 itself. It shows the code, the server's address and the steps for somebody without the app, with an Open in Hearth button that goes to hearth://join/ABCD1234?server=<address>. That works in every build, because the hearth:// scheme is registered unconditionally. The QR code in the app is the same web link, so a phone camera opens the page and Hearth's own scanner reads the server from it. parseInviteLink in utils/inviteCode.ts takes either, and an older link with only the code still works. On a phone that was never set up, the server it names goes into the server field, ready to confirm.

Android App Links, where tapping the link opens the app directly rather than a browser page that then offers to, need a concrete domain compiled in and verified against a file on that host. Every family self-hosts somewhere different, so no build ships one. Opt in at prebuild time:

HEARTH_APP_LINK_HOST=hearth.yourfamily.com npx expo prebuild --platform android

How background location works​

All of this lives in app/services/location/tracker.ts.

  1. TaskManager.defineTask(BACKGROUND_LOCATION_TASK) at module scope (imported from app.tsx as a side effect) so the OS can wake the JS runtime. The module writes a boot line to the diagnostics log saying whether the process is headless, and re-attaches the motion classifier if sharing is on, because a process the OS cold-starts for a fix runs this before any React code and used to run the whole journey without a classifier.
  2. The OS request for whatever tier the tracker is in, see below. currentOptions() is the one place it is derived from what the tracker believes, so a re-registration from a wake or a watch cannot disagree with the tier. On iOS every tier is Location.startLocationUpdatesAsync with those options. On Android the moving, driving and live tiers are the native tracking service in modules/hearth-motion, handed the same options as a request (applyAndroid, services/location/nativeTracker.ts). The parked tier is expo's resting request with no service. A wake brings the service up for the length of one fix, and a watch runs it at the live request on top of the resting one until the window ends.
  3. Each delivery → toFix() (adds battery and the tracker's own activity) → thin() (drops near-duplicates, and on Android applies the circle's distance filter to what is uploaded) → MMKV-persisted queue → flush() (single-flight upload, oldest first). The flush runs on every delivery whether or not it kept a fix, so an upload that failed earlier is not left waiting for the next kept fix, which on a parked phone is a quarter hour away. A retry already waiting keeps its timer, so a firewall that locked the address out isn't met with a request for every fix.
  4. While the app is in the foreground, a heartbeat asks for one Balanced fix at the circle's interval whenever the last fix is older than that, tagged source: "heartbeat". It reads the moving/stationary mode and never changes it, swallows a failed acquisition rather than showing it as an error, and trip detection and the possible-incident check both ignore its fixes. A fix still being acquired counts as fresh only until its deadline, so a native request that never settles cannot hold the heartbeat off for the rest of the process, which is what "not reporting while online" was.
  5. expo-background-task registers com.binary.rewind.hearth.sync to flush the queue, re-arm the fence and take a fix if the last one is stale, when the OS grants time, which is never while the app is open. Its fixes are bounded at fifteen seconds, since iOS gives the task about thirty. On Android it adds the fence again on every pass while parked, because Play Services drops fences without the app's own record of one knowing, and a pass that finds the record gone also looks for a departure. patches/expo-background-task@55.0.22.patch makes it an app refresh task on iOS (BGAppRefreshTaskRequest) rather than a processing task, which iOS tends to hold for a phone that is idle and charging.
  6. The server's response carries the current policy. If it changed, updates restart with the new intervals, throttled to once a minute.

Turning off Share my location in the app stops the OS updates entirely. No fixes are captured or queued. The phone tells the server it did, and the map shows its owner a banner saying so with a button to turn it back on.

The switch stays on through things that stop the tracker without the person asking, such as a session that ended, a change of server, a permission dropped below Always or Location Services switched off. refreshLocationStatus() runs every time the app comes to the front and after every sign-in. It stops the tracker when the permission or the switch rules it out, and starts it again once the phone is signed in, the switch is on, the permission is Always and Location Services are on. It never starts a tracker whose switch is off, and never one the server refused, see Uploads below. startTracking() runs one start at a time, so the app coming back and a sign-in in the same moment start it once.

The tiers​

The tracker is in one of three modes, moving, stationary or off. Inside moving it may also be on the driving tier, and in any mode it goes live while somebody is watching.

Moving is the walking tier. On Android it asks for Balanced accuracy on the circle's interval with no OS distance filter, so a still phone keeps delivering fixes the stop can be judged from, and thin applies the circle's distance filter to what is uploaded. On iOS it asks for High accuracy with the circle's distance filter at the OS: iOS 16.4 and later suspend a low accuracy session that has a distance filter once significant-change monitoring is also on, and expo-location always adds that, so a Balanced moving iPhone sat silent until relaunched.

Driving is the one state the GPS runs for its own sake. Speed and heading only come from GPS, and without this tier a driver's dot sat off the road and their speed read as noise. It starts when the classifier says automotive, or from speed alone on a phone without the permission, and asks for High accuracy every ten seconds with a distance gate that follows the speed in fifty metre steps. The step is always the one below nine tenths of what the car covers in ten seconds, so a steady drive keeps every fix. Rounded to the nearest step, the gate came out wider than ten seconds of travel at 50, 70 and 100 km/h, the OS dropped every other fix, and the trail cut the corners. The first fix after one at road speed that has come off it goes up whatever the gate says (thin). The server's possible-incident check calls a stop sudden only from a fix that stands still straight after road speed, and the gate held a standing car's fixes back for five minutes. An ordinary stop sends its slowing down instead, which is what tells the two apart. It ends when the classifier says otherwise, after three minutes of crawling, or when the phone parks. The drive, the classifier's last verdict and the start of its still streak are persisted in the tracking store rather than held in module scope: a process the OS cold-starts for a background event throws its React host away after every task, so each delivery used to begin a new drive, upload unknown, and lose the guard that keeps a queue of traffic from parking the phone.

Android reports a speed of 0 for a fix it never measured, where iOS reports -1, and a Wi-Fi or cell fix never measures one. toFix reads a zero on a fix looser than GPS class (30 m) as unmeasured, so the derived speed runs, the 6 m/s rule can start a drive without the classifier, and a run of network fixes under a flyover no longer ends one. A zero on a GPS fix is a real stop.

Parked (stationary) is quiet but never silent. Once the phone has stayed for five minutes inside a circle of 60 m, or 1.5 times the policy's distance filter where that is wider, the request steps down and an exit geofence is armed around where it stopped. A phone allowed only approximate location, Android's "Approximate" or iOS with Precise Location off, gets no fence, because neither OS arms one for it. It parks without one, and the resting request and the classifier are what notice it leave. Waiting for a fence that never came, it kept the full tier and ran the service all day. Before it steps down, while the moving registration still holds the process and, on Android, the service, the tracker takes one arrival fix, stamps it still whatever its own verdict, and waits for the upload. If the OS does not answer within thirty seconds a fix is made up from the parking spot, dated now, so the word still always leaves. The last fix of every journey is taken while the phone is still called moving, so without this the server never heard the stop and applied its hour rule to a phone sitting at home.

On Android the parked request is Balanced with one fix wanted a quarter hour and no foreground service, so nothing stays in the shade. On iOS it is a cell-only session (Accuracy.Lowest) with no distance filter, because a low accuracy session with one is the shape iOS 16.4 and later suspend once significant-change monitoring is on, and expo-location always adds that. It costs almost nothing, it keeps the process alive so the tracker's own timer can send a still fix every quarter hour, and a departure is seen by the session's own fixes rather than at the fence's leisure. On both platforms a parked phone's quarter hour fix is sent from the anchor, with the arrival fix's accuracy, whenever the fix the OS has does not clear the fence. Sent as it came, a cell-only session's estimate put a member at home hundreds of metres down the road on the family's map. The 200 m fence stays as a third signal. Whether a parked iPhone stays resident with this shape has not yet been confirmed on a device: the tracker log's heartbeat lines over a parked hour are the check. A parked iPhone used to run no session at all and was suspended within seconds: the arrival fix went with it, there was no heartbeat, and the fence, which iOS reports minutes late and not at all with Background App Refresh off, was the only way back.

Live is the tier for as long as somebody has the member's page open, or the member has an SOS open, see Watching below. Both reach the phone the same way, as a watchedUntil on the upload reply and a watch down the control channel, so the SOS screen only reports by itself on a phone that is not on Live.

The Android transport​

Android hands out continuous location only to a foreground service, and without one the phone is an ordinary background app: a few fixes an hour, none in Doze, no network until a maintenance window, a process reclaimed within minutes on most OEM builds, and a service start refused later except at a handful of moments. The family will not have a notification that stays, so the service runs while the phone is on the move (moving, driving, live) and goes with the stop. A parked phone runs expo's cheap resting request with no service, and a wake brings the service up for the length of one fix and drops it with the fix, which is the second-long "Updating your location" a messaging app shows when it checks for messages. A watch keeps the service up at the live request for as long as the window lasts, with the resting request still running under it. The notification is one plain line on a channel the service creates at minimum importance (hearth-wake, "Location updates"), not ongoing, so from Android 13 the person can swipe it away and the service runs on without it. The app's own channels, default, alerts and sos, come from setupChannels() in services/notifications.ts. setupPush() sends the phone's time zone and clock (services/phoneClock.ts) with its registration on every launch, since the server reads quiet hours and the time on late news on them.

Everything that has to survive the JavaScript process being killed is native Kotlin in modules/hearth-motion/android:

  • HearthTransitionReceiver and HearthFenceReceiver are declared in the manifest and are the targets of the activity transition request and the geofence around the parking spot (PlayServicesRequests.kt). Play Services starts the process to deliver to them, so a departure wakes a phone whose process is long gone. A geofence exit or an activity transition is a moment Android lets a background app start a foreground service, and the receivers start it right there, inside that moment, from what the tracker last told them (Departure.kt, TrackerPrefs). A vehicle transition or a fence exit on a parked phone brings the tracking service up with the moving request. On foot is confirmed first under the brief service, at most every two minutes. A transition on a moving phone whose service has gone brings it back. When Play Services sends the fence receiver an error, most often because Location was switched off and it removed every fence, the receiver forgets the fence and queues a fence_lost event, and the sync task adds the fence back.
  • HearthTrackingService is the foreground service and owns the location request through a LocationCallback, so the fixes of a journey are taken whether or not JavaScript is up. Each fix is appended to a native queue (TrackerQueue, one JSON document per line under filesDir), as is every event the receivers take. It is sticky: killed under a journey, it restarts with the request it persisted. startForegroundService throws at the call on Android 12 and later when the start is not allowed, and that is the refused the module answers with.
  • HearthHeadlessService runs the JavaScript tracker's headless task (HearthTracker, registered in index.tsx) once the service holds the foreground, which is what lets the process start another service. The task drains the queue (processNativeQueue): fixes first, through ingest like any delivery, then events, through the same handlers as the live ones (onMotion for a transition, onFenceExit for the fence). While JavaScript is up and listening the service and the receivers poke it directly (onNativeQueue) and it drains the same way.
  • HearthBootReceiver puts the requests back after a reboot or an update, which drop everything Play Services held, and restarts the service for a phone that was on the move. expo-task-manager restores the resting request on its own.
  • HearthMessagingService sits in front of expo-notifications' own and brings the brief service up inside the message handler for a wake or a watch push, the one other moment a background app may start a service. It does the same for a nudge_requested push, but only when Firebase hands that message to the app, which for a visible push means the app is in the foreground.

While Android refuses the service, a moving phone keeps expo's request running on the tier's options with no service of its own. Android holds a background app to a few fixes an hour, and each one is a chance to judge the journey until the service can start. Without it a refused phone had no request at all.

reassertService answers a refusal for a moving phone at every allowed moment: an activity transition, a fence exit, a wake or watch push and the app opening at once, and a delivery or the sync task once per ten minutes, since re-registering hands back the fix the OS already had and that is another delivery. A parked phone wants no service and is left alone unless somebody is watching it, when its live service is answered the same way. A brief service found under a moving phone is upgraded in place. A status of none while a tier wants the service means it went down without the app asking, an OEM battery manager most often. The tracker brings it back, remembers the moment for a day, and serviceDiedUnexpectedly() is what the health report sends as serviceStopped.

The battery optimisation exemption still matters: an exempt app may start its service from the background at any moment and keeps its network in Doze, so the resting request and the upload work on a parked phone that is not being woken. The checklist asks for it directly (ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, allowed for family safety apps) and, on the vendors that kill background apps, walks the person through the vendor's own settings.

The rules the receivers act on and the queue's store have plain checks in modules/hearth-motion/test/DepartureTest.kt, run the way the file says.

patches/expo-location@55.1.14.patch remains for iOS (the one-shot manager's allowsBackgroundLocationUpdates) and for the resting request's service state on Android, which the tracker no longer reads. Expo ships its modules to Android as prebuilt AARs, so apps/mobile/package.json lists expo-location and expo-notifications under expo.autolinking.android.buildFromSource. Every patch in patches/ is applied by scripts/apply-patches.mjs from the root postinstall, not by pnpm's patchedDependencies: with node-linker=hoisted, which Metro needs, pnpm applies a patch again on every install, and expo prebuild runs one, which left the Kotlin declared twice. The script applies each patch once and leaves one already in place alone.

One-shot fixes​

Every one-shot fix (reportNow, the heartbeat, the background clock, the sync task, the fence check, the classifier's confirmation) has a deadline inside acquireFix, thirty seconds, or fifteen for the sync task. Android's getCurrentPositionAsync has no timeout of its own, and a request a background app makes can hang for the life of the process. When the fresh fix is late, the fix the OS already has stands in where that is still useful: a wake, a nudge, the app opening, the clock. It doesn't stand in for a fence check, where a stale fix inside the circle would re-park a phone that has just left, or for the park fix. That one is made up instead: from the phone's newest real fix, with that fix's accuracy, if it is under two minutes old, and otherwise from the anchor. A stale anchor dated now once put a phone 174 m from where it was sitting.

Fixes that arrive in a batch, as the Android native queue hands them over, are labelled with the activity the tracker had when each one was recorded, not when the batch was read. A drive's last minutes, drained after "walking" had ended it, once went up as a walk at 80 km/h. The timeline of verdicts is kept in the tracking store for an hour, so it survives the restart that drains a queue.

A fresh request the OS answers from its cache, with a fix over a minute old, is asked once more inside the same deadline. An iPhone opened after hours away was handed the fix iOS still held from where the phone had been. Uploaded with its own timestamp, the server never moved presence back to it, and the row kept saying "two hours ago, at home". A second cached answer is all the OS has and is taken as it is. Every report done line in the diagnostics log carries the fix's age in seconds.

On iOS, expo-location's one-shot manager had allowsBackgroundLocationUpdates off, which Apple documents as a manager Core Location need not keep the app running to serve. The patch sets it when the app is authorised Always and declares the location background mode, so wakes, park fixes and watch answers are served in the background.

Calling the stop​

The classifier is the normal path: it calls a stop after ninety seconds of the phone reading still, three minutes inside a detected drive, and ends one the instant you start moving. A phone on a journey is not parked by the classifier alone: while it has travelled more than 400 m in the last ten minutes or done 3 m/s in the last three, a still verdict at the lights needs the position to agree, which means no fix clear of the anchor for the same five minutes the location path parks on. The anchor is kept through a drive for exactly this, though a drive is never parked from the location path.

The position watch is the fallback, for a phone that refused the permission or cannot classify motion. On iOS a phone that has not crossed the distance filter delivers no fix to judge, so the tracker's background clock, a React Native timer that RCTTiming keeps as an NSTimer while the session keeps the app alive, asks for one fix once no update has come for five minutes and judges the stop from it. For any of this the session must never pause: pausesUpdatesAutomatically is off in every tier, since a paused manager suspends the app with it and a suspended app calls no stop and arms no fence.

A parked phone does not leave on the classifier's word alone unless the word is a sure "automotive" (a transition, or a sample at 75% or better that is not just the verdict the phone parked on). Core Motion keeps calling a car idling in a car park automotive, and every such sample brought the GPS back. Handled in bed, a phone reads as walking at fifty or sixty percent, and taking that alone brought the full tier back to a phone going nowhere. On foot the verdict is confirmed by one Balanced fix, at most every two minutes, and one more about three minutes later if the first could not confirm it, since half a minute out of the door a walker is still inside the fence and its error. The second look is a timer, so it lands while the process is about, which is always on a parked iPhone and on Android only while Android keeps the process. Otherwise the fence is the judge.

Only a fix clear of the anchor by more than its own error, and no looser than 100 m, ends a stop. A 200 m Wi-Fi estimate 400 m off once cleared the fence by its own error and un-parked a phone lying in bed. A looser fix that clears the fence is a reason to take one Balanced fix and judge that instead. A phone with only approximate location never gets a sharper fix, so for it the old rule stands. A fence exit on Android brings the full tier up first, inside the allowed moment, and then checks the exit against one bounded fix: a sharp fix clearly inside the circle is a false exit and the phone parks again at once.

Deferred delivery is off in every tier. It looked like the OS batching for battery and is not: both of expo's consumers hold the fixes in the process, and the ones held were the last of every journey, the fixes that say where the phone stopped.

Uploads​

A failed upload (network, 5xx, 429) keeps the queue and retries on its own after thirty seconds, then a minute, then two, while the process lives. After that it waits for the next delivery, which flushes whether or not it kept a fix. An upload that gets through starts the ladder over. On Live the ladder is two, five and ten seconds, and the ten repeats, since the next delivery is a second away and waiting for it is no back-off at all. Every fix stays queued meanwhile. A live upload gets four seconds rather than thirty, and one that runs out goes again at once on a fresh request with the same fixes in the same order, because one stuck on a dead connection froze the viewer's map for as long as it waited. A second timeout in a row is a slow network rather than a stuck request, and the ladder takes over with the full time. A 4xx that is not an auth problem drops that batch so one bad fix cannot wedge the pipeline, and the next batch waits on the same ladder rather than going at once, since a firewall in front of the server can lock the phone out for a 4xx. A 403 from Hearth itself ends sharing. The tracker stops, says why on the You screen, and uploads nothing more until the next sign-in or the person turns sharing back on. A deactivated account answers that, though deactivating through the admin routes also revokes the sessions, and the phone then signs itself out on the 401 that follows.

What the phone says about itself​

services/health.ts sends PATCH /me/health with the checklist's reading of the phone. That covers the location permission level, whether location is precise, Location Services, motion access, Background App Refresh on iOS, battery optimisation and the Restricted background setting on Android, Low Power Mode or Battery Saver on either, the manufacturer in lowercase, on Android whether the location service died without the tracker asking, and whether Share my location is on. It goes when the app comes to the front, after a sign-in, after the checklist or the Sharing switch changes anything, and the moment Low Power Mode or Battery Saver flips, which the native module reports as an onPowerStateChange event. The tracker sends it from the background too, when it finds the service dead, a fence Play Services dropped, or a changed permission or Location switch, which the sync task and a parked iPhone's quarter hour timer read for it. Those sends go at most once in ten minutes, so a switch flapping in a car park cannot spam the server, and they read the switches without acting on them, since a tracker stopped in the background would stay off once Location came back. Otherwise it goes again only when something changes, the account changes, or a day has passed. The server keeps it on user_presence.health, projects it as issues on presence, and the rows put the first issue under the member's name once the phone is stale. Sharing switched off is named at once, whatever the staleness, and the member page shows a pill while precise location is off. A stale phone whose last reading was 5% or less, off the charger, reads "Battery likely ran out" instead (batteryRanOut in packages/shared). The server leaves what a flat battery explains out of its issues, power saving, battery optimisation and a restricted or stopped service, so an app that predates the line says the phone went quiet rather than blaming power saving. A switch somebody turned off is still named first. The offline sweep reads it too, so a quiet phone that has said why it cannot report is reported as that, not as offline.

Sharing switched off on the phone is a pause of every circle that allows one. Those circles see "Sharing paused" and no position, and hear no offline alert. A circle with pausing turned off is told plainly instead. It keeps the last position, faded once stale, the member's name says "Turned off location sharing", and its offline alert says that is why. A fix newer than the report reads as sharing back on, in case the report that said so has not landed.

Every fix carries activity from the tracker's own tiers, still while parked and on the arrival fix, driving on the GPS tier, the classifier's word while walking, so the server knows a still phone is meant to be quiet and calls it offline only after twelve hours rather than one. An account signed in only on Android phones gets about 100 minutes instead, the three wakes of a parked phone and the time to answer the last, since Android answers a wake even from Doze.

Diagnostics​

services/location/log.ts keeps the last thousand things the tracker did, on the phone, in chunks of a hundred so a line costs one short write. While somebody watches Live the deliveries and uploads are counted rather than logged, and go in as one live line every half minute and one when the window ends, so a window cannot wash the night's evidence out of the log. Otherwise it records boot with whether the process was headless, tier changes and why, fixes delivered and kept, report / report done / report failed for every one-shot fix with its source, accuracy and elapsed time, flush / flush failed with sizes and status, service with the Android service's status after every registration, reassert before and after, sync for each run of the sync task, watched and watch adopted when an upload reply starts or moves a window, wake, fence exits and whether they were false, fence lost, walk checked again, refused, resumed, the classifier's verdicts, and heartbeat skipped with the reason. A shared log opens with a header from headerForTrackerLog() with the mode, when the anchor was set, queue length, whether the server is refusing uploads, last error, permission, whether location is precise, service status, the watch window and the platform. The log holds no coordinates, so it can be pasted into an issue without saying where the phone parks. When a family member says "the notification stayed" or "it went quiet at home", this is the page to ask for. A stop that was called and a park fix that never left used to look identical.

The control channel​

The tracker keeps its own websocket to the server whenever its process is alive with a location session: on iOS in every tier, since the parked session keeps the app alive, and on Android while the phone is on the move, since the service is up then anyway. A parked Android phone holds nothing open. It sends { type: "control" } on open and nothing more of its own (the server pings it every two minutes and the socket layer answers), reconnects with backoff, and rotates its token itself when the server refuses it (services/location/control.ts). An ask from the family (a page opened, Live, the map opened, the sweep's wake) comes down it and is answered within a second. A wake takes one fix, a watch goes live, and a watch with no seconds left ends the window. Each ask carries an id, and the channel sends { type: "control", ack } back before it does anything else, because the server pushes instead when no answer comes within three seconds. The UI's socket in services/realtime.ts is a different thing: it lives with the screen and closes when the app goes to the background, which is exactly when this one matters. An ask for a parked Android phone goes by a silent, high priority Expo push, and HearthMessagingService in the motion module, which sits in front of expo-notifications' own service, brings the tracking service up brief inside the message handler: "Updating your location" for the one fix, gone with it, and stopping itself after the fix's deadline if nothing else does. That is the moment Android allows a service to start from the background, and the notification it posts is what keeps the app's pushes at high priority. The JavaScript task then takes the fix under it (wakeFix in tracker.ts, startBrief in services/location/nativeTracker.ts), or, for a watch, upgrades that service in place to the live request.

Opening a member's page calls POST /circles/:id/members/:userId/refresh for one fix now, the way opening the map calls POST /circles/:id/locations/refresh for everyone quiet. The tracker also keeps the circles' places (stores/places.ts, filled by every places query and refreshed by the tracker itself once a day) and uploads the fix that crosses into or out of one at once, whatever the distance gate says, so an arrival is announced on the crossing fix rather than on the park fix minutes later.

Watching​

The Live page is for watching somebody right now, a car along a road or a child about to come out of school. The page calls POST /circles/:id/members/:userId/watch on focus and every minute after. Each viewer holds a window of their own, and the phone's is the latest of them. The server sends the ask down the phone's control channel when the phone says it has it within three seconds, or a silent watch push otherwise, and pushes again when the phone has said nothing for twenty seconds and the last push is ninety seconds old, three times per window at most. That catches a phone whose live service Android stopped mid window. The reply says which (pushed), when the phone was last heard and what it said stands in its way. Every upload reply also carries watchedUntil, so a phone that is already reporting picks the watch up on its next batch whether or not the push arrived. Either way the phone puts watchedUntil in its store and currentOptions() returns the live tier whatever the phone was doing, at High accuracy with a fix a second and every one uploaded. A parked Android phone is no exception. The watch push brings the brief service up, the live request upgrades it in place, and the window's end takes it down. The resting request runs under it the whole time, so a service Android kills inside the window, or one a reboot takes, leaves the phone reporting as it was. Where Android refuses the service anyway, the phone answers with one fix, and the window stays held so the next upload reply doesn't ask again. iOS has no time interval, so the live session has no distance filter either, which is the only session shape that delivers to a phone standing still. ingest thins the stream at the live tier's own pace rather than the circle's. The phone also answers with one fix straight away. A hold on a window already live only moves its end, with no fresh fix and no re-registration, though a hold that came by push still brings back a live service Android stopped, since a push is a moment Android allows the start.

The page lets go with DELETE on the same path when it loses focus or the app goes to the background. Inactive, under Control Centre or a call, it goes on holding. With nobody else watching the server ends the window, and the phone hears it at once. An upload reply with no window, or a shorter one, ends or shortens what it holds, and so does a watch with no seconds left down the channel. A reply to an upload sent before the latest ask can't end it, since the ask is newer news. Otherwise the window ends by a timer and by the first fix past it, whichever comes first, and the request steps back to the tier it was in. The page sends neither the release nor a trail fill to a server whose watch reply has no supports.

The Live page calls the phone live on any fix from the last thirty seconds, counting one from up to thirty seconds before the ask, since the one fix an ask gets out of an iPhone is stamped before the ask reached it. It holds the display on for as long as it is open (expo-keep-awake, its own tag), since following someone along a road is watching rather than reading. Asks closer together than ten seconds go as one a moment later, so pulling to refresh can't reach the server's sixty in ten minutes, and a 429 says there were too many asks, not that the server could not be reached. The map follows them with a short linear glide until the viewer moves it, and then stays put, with a Follow button to pick them up again.

The button that opens it is offered for anyone the viewer sees precisely, parked or on the move (liveOffered in utils/activity.ts), and never on your own page. A phone that doesn't answer is said to be not answering, with the reason.

Where somebody is, in words, comes from the phone's own geocoder (utils/geocode.ts), with no Hearth server and no account. It is not private to the phone. reverseGeocodeAsync hands the coordinates to Apple on an iPhone and, on most Android phones, to Google, which answer over the network. It is on by default, and Street names under You turns it off (streetNames in stores/settings.ts), after which useNearby asks nothing. Answers are cached on a hundred metre grid. Android's geocoder falls back to an Open Location Code where it has no street ("8H+2W", "7JVW4XPP+2H"), which is a coordinate spelled differently and means nothing to a family, so plus codes, bare house numbers and postcodes are read as no answer and the district, town or region is used instead. All of the geocoder's answers are tried in turn, since the plus code is often the first and the street the second.

A member settled somewhere no place covers gets a "Save this spot as a place" row on their profile, which opens the place editor on their position with the geocoder's name as a first draft.

The Live page draws the fixes that arrive while it is open and nothing else, every fix of each upload, from the socket's fixes (useLiveTrail). After a reconnect or a return to the front it fills what it missed from GET .../live-trail, since the last point it drew. A fix far looser than the last few, a Wi-Fi grade fix in a stream of GPS ones, is left out of the line (liveTrailToDraw in utils/trail.ts), and so is a step under five metres. A trip draws its own trail, and only where the phone reported it: a silence between two fixes is dashed (splitTrail), because the road between them is a guess. The profile map draws no trail.

Opening the map calls POST /circles/:id/locations/refresh, which sends one wake to each member quiet for a couple of minutes. Each wake goes down the phone's control channel when it has one open, and otherwise by silent push, at most once every ten minutes per phone. Only PUSH_PROVIDER=expo can send that push. The map route skips members who have paused, and the member route asks only a member sharing precisely. The server's own wake, after ten minutes of quiet from a moving phone or half an hour from a parked one, is a second line behind the phone's heartbeats, not the heartbeat itself.

Crash detection​

apps/mobile/modules/hearth-motion, a local Expo native module written in Kotlin and Swift, does the sampling. The accelerometer runs fast enough to catch an impact (20 ms), and the gyroscope and barometer run far slower (100 ms and 200 ms) because rotation and pressure move on human timescales. Samples cross the bridge batched roughly four times a second rather than one call per sample.

On Android the module registers its SensorManager listener from the application context. expo-sensors drops its listener the moment the Activity pauses, and a screen going off or a map app on top is the state a phone is in for most of a drive, so the detector used to run only while somebody was watching it. Registering from the application context keeps samples arriving for as long as the process lives, which the location foreground service already guarantees while the phone is moving.

app/services/location/driveSensors.ts receives those batches, assembles the rolling window, and calls detectDriveEvent() in packages/shared/src/impact.ts, which is where the reasoning about what the sensors can and cannot claim is written down. expo-sensors is the fallback only, for an install built without the module. It is also the path the ACCEL_INTERVAL_MS, GYRO_INTERVAL_MS and BARO_INTERVAL_MS constants in driveSensors.ts belong to, since the native sampler sets its own rates.

One switch gates it, and it samples only between the two ends of a drive. The circle has to have Possible-incident alerts on, which the app mirrors into device storage because the detector runs from a background task where the query cache may be cold. The OS classifier, which runs whenever tracking does, is what says you are in a vehicle, because sampling this hard is only worth its battery while you are driving.

A verdict does not alert anybody. It goes into a persisted store and the app asks the person, with a countdown. Answering dismisses it. Silence escalates to a real SOS, because someone hurt badly enough not to answer is the case the feature exists for. The store survives a process kill on purpose: the phone that just took the impact is the one most likely to be restarted by it.

What happens next lives in app/services/incidentAlarm.ts, not in the prompt. IncidentPrompt is only mounted while the app is in front, and a phone after a crash is usually locked, where JavaScript timers stand still. So nothing waits on a timer. checkIncident() reads the store and the wall clock and does what the incident needs at that moment. It puts the countdown notification up while the app is not in front, decides once the 30 seconds are past and the incident is under five minutes old, retries an alert that found no signal, or drops one an hour old. It is called from the tracker's flush(), which every delivery, retry and sync pass goes through, with online: true from an upload that got through, and from the prompt's tick and its AppState listener. The verdict in driveSensors.ts follows the same rule. It is armed with a timer for the foreground, and any sample past its deadline judges too, since native batches keep arriving with the screen off.

The alert itself is raiseSos(circleId, note, "incident") through a circle with incident alerts on, from the query cache or from GET /circles when the cache is cold. The server raises it in every circle of the caller that has them on. Only a network failure, a 5xx, a 429, a firewall's page or a 401 with tokens still held is retried, spaced 15 s, 30 s, 1 min and then 2 min apart, and at once after a successful upload. Anything else is a refusal and ends it, because asking again into a firewall that blocks on a 4xx takes the household offline. Nothing is sent without a session, and an incident is dropped if a different account signs in. The module requires the tracker for its fix at the moment it needs one, rather than importing it, because the tracker imports the module and a cycle resolved while the app loads can leave one side undefined.

The notification uses the fixed id hearth-incident, the SOS channel (on the trigger, which is where expo-notifications reads it), interruption level timeSensitive and a category with one action, incident-ok. On Android the action does not open the app. expo-notifications runs the registered notification task for it, and NOTIFICATION_WAKE_TASK in services/notifications.ts passes it to answerIncidentResponse() before anything else. On iOS the action opens the app, because a killed app never hears a background action. Whatever ends the incident dismisses the notification.

The server runs its own, unrelated check on the breadcrumbs it receives, and it needs no sensors and no settings on the phone. Fixes half a minute apart cannot tell a collision from parking hard, so all it claims is that somebody stopped suddenly after driving fast, and it posts that to the circle as a prompt.

Realtime​

app/services/realtime.ts holds a single websocket while the app is in the foreground and writes every message straight into the TanStack Query cache. It disconnects in the background, where push takes over, and reconnects with back-off. Inactive is not the background, so pulling down Control Centre on an iPhone leaves the socket open. It pings every 25 s, every 5 s while a Live page is open, and opens a new socket when one goes 10 s, or 4 s, without a word, since a half-open socket looks open and carries nothing. Presence from the socket and from the minute's poll is merged by recordedAt (utils/presence.ts), so an answer that lost a race never steps a dot back. The map and the Live page show a quiet "Reconnecting…" once the socket has been down for five seconds (useRealtimeDown).

Both that socket and the control channel hand every frame to app/services/socketAlerts.ts. On a server whose push provider is none or webpush, or where this phone's push setup came back unsupported, it posts a local notification on the SOS channel for an sos frame about somebody else and for an event frame of type possible_incident. An SOS rings once per raiser and startedAt, because one press opens an alert in every circle in a single transaction and they share its start. The notification comes down again on sos_resolved once every alert of that press it heard is resolved, which only a phone still connected at the time hears. It carries ids only, the same data a push would.

Structure​

app/
components/ Avatar, GlassPanel, MemberMarker, SosHoldButton, IncidentPrompt, ListRow,
HearthMap and the rest
screens/ Server, Login, Register, Permissions, KeepAlive, Map, MemberDetail,
Live, Places, PlaceEditor, PlaceDetail, Activity, Circle,
CircleSettings, Invites, Sharing, NotificationPrefs, You, Devices,
PrivacyData, ChangePassword, MapStyle, TrackerLog, Sos, CheckIn,
Trips, TripDetail, Admin, CreateCircle, JoinCircle
navigators/ AppNavigator (auth gate + stack), MainTabNavigator (Map / Places / Activity / You)
hooks/ queries.ts (TanStack Query), queryKeys.ts, useActiveCircle.ts,
useLiveness.ts, useMapStyle.ts, useNearby.ts
services/ api/ (fetch client + typed endpoints), realtime.ts, notifications.ts,
health.ts, permissions.ts, location/ (tracker.ts, nativeTracker.ts,
control.ts, motion.ts, driveSensors.ts, log.ts)
stores/ zustand: auth, settings, tracking, places, incident, nudge, push,
alert, toast. mmkv (persisted-store adapter), tokenVault (SecureStore)
theme/ Ignite theming with Hearth's light/dark palettes
i18n/ en.ts. v1 is English only, and a locale is a file typed as `Translations`
utils/ activity, geocode, trail, format, storage (MMKV) and the rest
modules/ hearth-motion: the Expo native module behind the motion classifier,
the batched sensor samples and, on Android, the tracking service,
its receivers and the native queue

Design notes​

  • Dark-first, warm near-black surfaces so the map and avatars carry the colour.
  • One ember gradient reserved for the primary action on each screen.
  • Status is communicated by the avatar ring: blue = you, red pulse = SOS, dashed amber = approximate, grey = stale.
  • Glass panels float over the map, with blur intensity reduced on Android.
  • SOS requires a three-second hold with a visible progress ring, never a tap.
  • A toast is read out to a screen reader as it appears, since focus never reaches it before it goes. It stays up longer the longer it is, at least six seconds for an error and at most fifteen, and a tap dismisses it early.
  • The header's back arrow and close cross are labelled Back and Close for a screen reader.
  • Counted strings use i18next's _one keys beside the bare key, so one of anything never reads "1 members". A time ahead reads "in 3 days". One less than five minutes ahead reads "just now", since a phone clock running fast stamps fixes that far ahead and the server takes them.

Testing​

pnpm --filter hearth-mobile compile # tsc
pnpm --filter hearth-mobile lint
pnpm --filter hearth-mobile test # jest-expo unit tests

Maestro flows (.maestro/) aren't included in v1. See the roadmap.