Distance Goal

How Distance Goal is built

What the app is made of, where its data lives, how a shared link works, and how releases are made and tested. Distance Goal is not open source, but we are open about how it is built. Signing keys, credentials and security detail stay private.

File names on this page come from the app's private repository: they are there to orient you, not to link to. Why it is built this way is on the decisions page.

What it is made of

Distance Goal is one codebase for Android and iPhone. The only feature that differs between them is Health Connect auto-tracking, which exists only on Android.

  • Expo SDK 57 on React Native 0.86, written in TypeScript in strict mode, with the React Compiler on.
  • expo-router for navigation, with typed routes: each file in src/app is a screen.
  • No server, no account, no network requests. The app's own code opens no connection. Library code that could — Firebase Messaging, which comes with expo-notifications — is in the app and never initialised. Everything lives on the phone.

Where things live

  • src/app — screens, one file per route. _layout.tsx wraps the stack; +native-intent.tsx routes incoming links.
  • src/components — shared components. ui/ is the design system: buttons, cards, the progress ring, form inputs.
  • src/lib — the app's logic, almost all of it pure TypeScript and unit-tested; four thin adapters touch the phone (below). types.ts is the data model.
  • src/store — persistence and state: loads and saves the app's data through AsyncStorage, and hands it to screens through React context.
  • plugins — Expo config plugins, all of them Android-only.
  • relay — the retired buddy-group server, a package of its own. Not part of the app.

Everything lives on the phone

Everything a person enters is one document, AppData, stored on the phone with AsyncStorage. There is no cloud copy and no sync.

  • One versioned document. It was at schema version 14 in September 2026. src/lib/storage.ts holds the whole migration chain from version 1, tested end to end. Each new version adds the same pieces: an interface, a type guard, a migrate function, a step up from the version before, a case in the switch, and both version constants in types.ts.
  • Kilometres underneath. Every stored distance is in kilometres; miles are only a display setting.
  • Calendar days as strings. A date that means a calendar day — a goal's window, an entry's date — is a local YYYY-MM-DD string, never a timestamp, so a walk logged on 12 July stays on 12 July in every timezone.
  • Corrupt data is backed up before it is salvaged, never thrown away.
  • Nothing is dropped when a feature goes. The buddy-group fields are still in the model, so phones that had groups lose nothing.
  • Off the cloud, on to the next phone. On Android, cloud backup is switched off for the app's data, and phone-to-phone transfer is deliberately left on: with no account, it is the only automatic way to keep everything when changing to a new Android phone. Why transfer stays on.

Pure logic, tested in plain Node

Every decision the app makes — what counts toward a goal, streaks, badges, journeys, the share card, the link format, which reminders to schedule, migrations, export, restore — is a pure function in src/lib, tested by Vitest in plain Node, with no device and no simulated browser. Screens and stores sit on top. The whole suite runs in about a second.

Four modules in src/lib are the exceptions, because they are where the app touches the phone:

  • storage.ts — AsyncStorage.
  • export-share.ts — the file system, the share sheet and the document picker.
  • reminders.ts — expo-notifications.
  • health-connect.ts — Health Connect.

They are kept thin: the decision goes in a pure function beside each one, and the adapter just carries it out. The storage tests are the only ones that mock anything (AsyncStorage).

That is a testability rule and a portability rule at once: health-connect.ts imports a native module that throws on load on iOS, and it crashed the iPhone app at startup until it was put behind a platform file (below).

The native pieces

Each native module is there for one job.

The table scrolls sideways.

Native modules and what each is for
ModuleWhat it is forWorth knowing
react-native-health-connectAuto-tracking, on AndroidReached only through src/lib/health.android.ts
expo-notificationsLocal remindersNever asks for a push token. It statically links Firebase Messaging — never initialised, which is not the same as not there — and ShortcutBadger. Between them they bring the INTERNET, ACCESS_NETWORK_STATE, WAKE_LOCK and c2dm.RECEIVE permissions, and about twenty launcher-badge permissions
react-native-shareSending the card and the message togetherIts photo-library references are why the iPhone app carries two photo-library purpose strings
react-native-svgThe progress ring and the share cardThe card is SVG, exported as an image with toDataURL. The image comes out at the card's on-screen pixel size, so it is smaller on a lower-density phone (984 × 984 on a 3× screen)
expo-file-system, expo-sharing, expo-document-pickerExport and restoreAdd no permissions (checked against 2.6.1's manifest)
expo-dev-clientDevelopment buildsWhy Expo Go cannot run this app: it needs native modules Expo Go does not include

Config plugins. Four Expo config plugins shape the Android build. All four are Android-only by construction, so none can break an iPhone build.

  • with-health-connect — the two health permissions, and the permissions-rationale intent filters Health Connect requires.
  • with-data-extraction-rules — keeps the app's data out of cloud backup while allowing phone-to-phone transfer.
  • with-r8-optimization — turns on R8's optimiser, which the generated project's default ProGuard file switches off with -dontoptimize.
  • with-release-signing — lets a release built locally be signed. The key itself is kept outside the repository.

The android and ios folders are generated, not kept in the repository. A hand edit there lasts until the next prebuild, so anything that must stick goes in a plugin.

Health Connect on Android, nothing on iPhone

Auto-tracking reads distance from Health Connect, Android's store of health data. It is off by default and needs two opt-ins: a setting for the whole app, and a switch on each goal.

What is read

  • Two read permissions, no more: distance and, from 1.6, exercise. The app never writes to Health Connect.
  • From a workout, four things: its start time, end time, activity type, and which app recorded it — used only to tell a walk from a ride, because distance records carry no activity type.
  • Never the route. The exercise-routes permission is not declared, so Health Connect cannot return coordinates. Never steps, heart rate, calories or sleep.
  • Records are read again, not stored. Each time the app comes to the foreground it reads again. What it keeps is what the person's own choices need: one figure per day (so a badge can never be un-earned), the activity labels they set by hand, the sources they exclude, and — for a record they mark as a duplicate of a walk they logged by hand — that record's start, end, distance and source, so it stays uncounted.
  • A total blended from Health Connect is still Health Connect data. Google's terms cover data derived from or aggregated with Health Connect as well as the records, with no exception for blending. So a goal total that includes auto-tracked distance leaves the phone only when the person sees it and sends it themselves — a card, a link, an export — and never for advertising, data brokers, or insurance, credit or employment decisions.
  • What is read is a published claim. The fields taken from each record are listed in one place in health-connect.ts. They include which app recorded it (metadata.dataOrigin), which is shown to the user and makes excluding a source possible. Add a field there and the privacy policy and the Health Connect declaration are wrong the same day.

The privacy policy sets out every field in plain words: Privacy Policy.

One interface, one Android implementation

The table scrolls sideways.

The files that make up the health boundary
FileWhat it is
src/lib/health-provider.tsThe HealthProvider contract, pure helpers, and UNAVAILABLE_HEALTH. Imports nothing native.
src/lib/health.tsThe resolver. Exports healthProvider, which is UNAVAILABLE_HEALTH on every platform without a file of its own.
src/lib/health.android.tsThe only path to react-native-health-connect.
src/lib/health-connect.tsThe Health Connect implementation of the contract.

Everything imports healthProvider from @/lib/health, never the Health Connect implementation directly. Which file is used is decided by its name, per platform, so breaking the rule is a bundler error rather than a comment someone has to remember.

Why it matters. react-native-health-connect calls TurboModuleRegistry.getEnforcing as soon as it loads and ships no iOS code, so on iPhone it threw the moment it was imported — before any platform check could run. After any change to this boundary, both bundles are checked: the Android bundle must still contain the Health Connect calls (aggregateRecord, getSdkStatus) and the iPhone bundle must not. An Android bundle that has quietly lost the library is the worse failure, because it looks like nothing.

Two promises the contract makes that the type system cannot check:

  • Totals are exact; the record scan may be capped. Goal totals come from Health Connect's own aggregate. The record-by-record scan behind the diary, History and the per-source breakdowns stops after a fixed number of pages, so anything built from it can under-report on a very long goal with a chatty tracker. That is a safety limit, not a bug.
  • Fail closed, quietly. Nothing throws: a failure resolves to unavailable, false, zero or empty.

Smaller pieces: health-typing.ts maps Health Connect's exercise types to the app's activities (a development-time check in health-connect.ts guards that table against the library drifting); health-duplicates.ts handles a hand-logged walk that a gym app also recorded; and source-overlap.ts spots two apps recording the same walks (from 2.6.2).

On iPhone

The iPhone app reads no health data at all. iOS resolves to UNAVAILABLE_HEALTH, and the Health Connect library never reaches the iPhone bundle.

Adding Apple Health would mean writing src/lib/health.ios.ts exporting a HealthProvider, and nothing else would need to know. What would have to go with it:

  • Empty source lists on iPhone. The stored lists of excluded and shared sources hold Android package ids written by real installs. They can never match an Apple Health source; reusing them is at best inert and at worst a future collision.
  • A new activity table. HealthKit's workout types are a different set of numbers with different meanings, and need their own drift check.
  • A decision on wording that is specific to Health Connect, such as asking someone to update the Health Connect app — a decision, not a rename.
  • Documents first. HealthKit purpose strings, the Health section of Apple's App Privacy label, the privacy policy's “On iPhone the app reads no health data”, and the iPhone listing all change in the same release. The rule that documents change first.

How sharing works

Sharing goes outward. The app never sends anything itself: it hands a picture and a message to the phone's share sheet, and the app the person picks from it — a chat, a message, an email — does the sending.

  • One action. “Share progress” on a goal shows the card and, under it, the message that goes with it. One button, Share my progress, hands both to the share sheet together; Copy the message is the fallback. Why there is one button.
  • The card is drawn on the phone as SVG, with the same library as the progress ring, and exported as an image.
  • The message is written by shareCaption() from the card's figures and the goal's name, and ends in the link.
  • Both travel together through react-native-share, which sends an image with text as its caption, on both platforms, from one call. expo-sharing sends only a file, and React Native's own Share cannot attach a file on Android.
  • What leaves the phone is what the person saw. The card and the link carry the goal's total exactly as the ring shows it, and the card is shown before it is sent. On an auto-tracking goal that total can include Health Connect distance — which is why it leaves only when the person sends it themselves (Health Connect's rule), and the privacy policy says so.
  • Nothing comes back. The app does not know whether a link was ever opened.

How releases are made

  • Builds are made with EAS, Expo's build service. There is no Mac in the loop, so every iPhone-native mistake — a bad plugin, a bad Info.plist key, a missing entitlement — shows up only on a cloud build. That loop takes minutes, not seconds, so iPhone configuration changes are batched, and the one local check is resolving the app's configuration, with and without the development variant.
  • An Android release can also be built locally, signed with a key that is kept outside the repository.
  • Version numbers are bumped by hand. The version name, the Android versionCode and the iOS build number live in app.json (eas.json sets appVersionSource to local), so every change shows up in a diff.
  • No number means two things. Two different builds never share a version name, because Settings shows only the name. A build number is never reused once a signed build exists — even one no store ever saw — because a real signed file on disk can be uploaded by mistake. Why numbers are never reused.
  • Every build is handed over with its store release name, its store notes and a changelog entry. The changelog lists every build, including the ones nobody saw, and it is the one place that says which build is live: What has changed in each version.
  • Development builds install beside the store app, never over it. A development build has its own app id (uk.co.bluetorch.distancegoal.dev), its own name (“Distance Goal (dev)”), its own link scheme and an amber icon. The variant is applied when the native project is generated, so the project is regenerated explicitly and its app id checked before anything reaches a phone. On 8 August 2026 only Android's signature check stopped a debug build that had picked up the store app's id from overwriting real data.

How it is tested

  • Every change: the unit tests (npm test — Vitest, src/lib only, about a second), the TypeScript compiler (npx tsc --noEmit) and lint. The retired relay has its own package, configuration and tests, and is left out of all three.
  • End to end means the last screen. A flow that crosses into another app — a share, a link, a notification — is proven by following it on a real device, on the bundle that actually ships, to the final screen. “The app opened” is not a pass. The fragment-versus-query problem in the link format was found only that way, by following a real link on a device running 2.6.0 as it shipped.
  • Outward-facing work is verified by observing it. Fetch the address. Read the manifest. Check the timestamp. A claim repeated in six documents is not six pieces of evidence.

Routing is tested on the development build by firing the link at it directly:

adb shell am start -a android.intent.action.VIEW \
  -d "https://distancegoal.bluetorch.co.uk/join#v=1&ch=hadrians-wall&t=135&s=2026-09-06&e=2026-12-01" \
  uk.co.bluetorch.distancegoal.dev

Link verification can only be tested on a build installed from Google Play, because Android verifies links when an app is installed, not when it is reopened. This should report both hosts as verified:

adb shell pm get-app-links uk.co.bluetorch.distancegoal

The development build is deliberately left out of assetlinks.json. Both apps sit on the same phone, and two apps claiming the same links would break the very thing being tested.

The device script, for any release that touches sharing or joining — two phones, one of which once had a buddy group:

  1. 1Share my progress from phone A into a chat: the picture and the message arrive together. Tap the message on phone B: the join screen shows the goal and A's snapshot, and Start this goal creates it, then says “You're in”.
  2. 2A goal whose window has ended: B is offered Start it from today, and the new goal has the same length.
  3. 3The identical goal already on B: “You're already on this one”, both percentages, and Share your card back.
  4. 4An old DG1. invite, tapped or pasted: explained, not an error.
  5. 5The phone that had a group, after updating: no Buddies tab anywhere, and every goal, entry and badge still there.
  6. 6All of it in airplane mode. None of it needs a connection; that is the claim, and it takes a minute to check.

Lessons already paid for

  • A spinner must be ended by the thing that started it. Binding a pull-to-refresh spinner to a global flag left it running for ever on iPhone: one that is already refreshing as it appears does not reliably end, and a flag cleared only on success stays set after an error.
  • A display decision made inside a rate-limited background pass is not applied on tap. What the screen shows is filtered when it is drawn, from current state; the background pass only reads and writes.
  • Comments explain the decision, not the code — especially why the obvious alternative is wrong. Several have stopped a bug being reintroduced.
  • Say what is not done. Residual risks, caps, things that have never run on a device: written down. This project's failure mode is a stale claim, not a broken build.

More about Distance Goal

Distance Goal is published by Bluetorch Consulting Ltd.

What has changed in each versionWhy it is the way it is