Skip to content

Apps and clients

Mobile app setup

The mobile app comes as its own source, next to the backend's. It is an Expo React Native application using Expo Router, Redux Toolkit, Axios, Stripe React Native, Pusher, AsyncStorage, maps, location, localization, and other Expo modules.

Requirements

  • Node.js.
  • npm.
  • Expo CLI or npx expo.
  • Android Studio for Android emulator builds.
  • Xcode for iOS builds on macOS.
  • Apple Developer account for iOS publishing and Apple Pay.
  • Google Play Console account for Android publishing.
  • Stripe account.
  • Google Maps API key.
  • Pusher app credentials.

Install dependencies

From the mobile app root:

npm install

Start Expo:

npx expo start

Run on Android:

npm run android

Run on iOS:

npm run ios

Brand and backend URL

Everything that identifies a client's app (name, bundle ID / package, URL scheme, EAS project, Apple Pay merchant ID, icon, splash and brand colours, permission texts, support email and the backend URL) lives in one manifest: brands/<slug>/brand.json plus its assets/ folder. Create a client's brand from the default one, then apply it:

npm run brand:new -- example-delivery --name "Example Delivery" \
  --bundle-id com.example.delivery --api-url https://delivery.example.com/api \
  --support-email help@example.com --logo ~/Downloads/example-logo.png [--primary "#F97316"]
npm run brand:apply -- example-delivery

Colours and icons come from a logo and one colour. theme.seeds.primary (plus an optional secondary for the text colour) generates every shade, the dark theme and the text colours, each checked against WCAG AA: a light brand colour gets dark text on its buttons. Without --primary, the colour is suggested from the logo; npm run brand:suggest -- <slug or logo file> lists the candidates. From assets.logo (a transparent PNG or an SVG, ideally square and 1024 px), brand:apply makes the iOS icon, Android's adaptive and themed (monochrome) icons, the light and dark splash images, the white Android notification icon and web icons. Finished images in assets.icon, assets.splash or assets.notificationIcon are used instead, and hand-tuned ramps in theme.brand, theme.neutral or theme.ink win over generated ones; brand:apply stops with the pairs that fail if they aren't readable.

npm start, npm run ios|android and npm run prebuild apply the brand named by the BRAND variable (default onlyparcels) before they run. Don't edit app.config.ts for branding; it reads the applied manifest.

For local development, point the app at a local backend without changing the manifest:

EXPO_PUBLIC_API_BASE_URL=http://127.0.0.1:8000/api   # iOS simulator
EXPO_PUBLIC_API_BASE_URL=http://10.0.2.2:8000/api    # Android emulator

For production, always use HTTPS.

Order, store, store-type and promo links are https links on the backend's domain (/o/{id}, /s/{id}, /t/{id}, /promo/{code}). On a phone with the app they open it on that screen; otherwise they open a web page that offers the app. Order confirmation emails use them, and so does the share button on a store's page (shown only when the brand has a linkDomain).

  1. In the brand's brand.json, set identity.linkDomain to the backend's host (for example eats.example.com), run npm run brand:apply and build the app. That adds the iOS associated domain and the Android app-link filters.
  2. In the admin, Settings → Integrations: Apple team ID, iOS bundle ID, Android package, and the Android signing certificate's SHA-256 fingerprints (from the Play Console's App signing page), plus the App Store and Google Play links for the web page. Set Mobile app URL scheme to the brand's identity.scheme.
  3. Check that https://<domain>/.well-known/apple-app-site-association and /.well-known/assetlinks.json return JSON, and that Settings → General → Public backend URL is that same https address.

Build environment

Keys are read from the environment when the app is bundled, so they never live in the repository:

EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_or_test_xxx
EXPO_PUBLIC_GOOGLE_MAPS_API_KEY=your_ios_maps_key
EXPO_PUBLIC_GOOGLE_MAPS_API_KEY_ANDROID=your_android_maps_key
EXPO_PUBLIC_PUSHER_KEY=the_client_pusher_key
EXPO_PUBLIC_PUSHER_CLUSTER=eu
EXPO_PUBLIC_SENTRY_DSN=https://key@o0.ingest.sentry.io/0   # optional, see Crash reports

Currency, country, default language, support email and the legal pages are not app settings: the app reads them from the backend (GET /api/v1/platform).

Building and publishing

Store builds come from the client's own Apple and Google accounts, in one of three ways. docs/build-local.md in the app has the full steps.

  • GitHub Actions (recommended): the Brand build workflow (.github/workflows/brand-build.yml) builds a signed .aab and .ipa for a brand, and can upload them to Play internal testing and TestFlight. Its secrets live in a GitHub Environment named after the brand. It checks them before building and fails with the secret's name if one is wrong. The signing files are made once with bundle exec fastlane android bootstrap_signing and bundle exec fastlane ios bootstrap_signing (an App Store Connect team key is enough for iOS).
  • A local machine: the same commands the workflow runs (expo prebuild, Gradle, fastlane ios build).
  • EAS Build on the client's Expo account (eas build --profile production). The client's Google Play service-account key is uploaded to EAS for eas submit; eas.json holds no personal account details.

Store builds set APP_VARIANT=store, which removes the development-only permission to load plain-http addresses. The version users see is identity.version in brand.json. Build numbers come from the workflow run (plus BUILD_NUMBER_BASE for an app already in the stores), or from EAS (appVersionSource: remote; set the current number once with eas build:version:set).

Over-the-air updates

JavaScript fixes can reach installed apps without a store review through EAS Update, in the client's own Expo project (identity.easProjectId): an update for one brand can never reach another brand's app. Store builds listen on the production channel, and EAS preview builds on preview. The simplest way is the Brand update workflow: it refuses the update if native code changed since the brand's last store build, then publishes. By hand:

npm run brand:apply -- example-delivery
APP_VARIANT=store eas update --channel production --message "Fix checkout total"

The app downloads the update in the background and uses it from the next launch.

  • An update only reaches builds with the same native fingerprint (runtimeVersion: fingerprint; fingerprint.config.js leaves out the version and build number). Anything native changes it and needs a store build, for example: a native library, a permission, a config plugin, a brand asset or colour, or an Expo SDK upgrade.
  • Always apply the client's brand, and set APP_VARIANT=store, before eas update: the update is built from the applied brand, with the store builds' configuration.
  • To undo a bad update: eas update:rollback.
  • A store build needs the brand's Expo project: brand:apply refuses a store build (APP_VARIANT=store) while identity.easProjectId is still the placeholder.

Crash reports (Sentry)

Optional, in the client's own Sentry account. Add the project to the brand:

"services": {
  "sentry": { "dsn": "env:EXPO_PUBLIC_SENTRY_DSN", "organization": "client-org", "project": "client-app" }
}
  • Only store and preview builds report. Development and builds without a DSN send nothing.
  • Reports carry the app version, build number and the OTA update id, plus the screens visited before the crash. No personal data: no user, IP address, screenshots or request bodies.
  • If a screen fails to render, the user sees "Something went wrong" with a Try again button instead of the app closing, and the error is reported.
  • For readable stack traces, create a Sentry auth token and store it as the EAS secret SENTRY_AUTH_TOKEN: builds upload their source maps automatically. With organization/project set but no token, builds fail; set SENTRY_DISABLE_AUTO_UPLOAD=true to build without uploading. After eas update, upload the update's source maps with npx sentry-expo-upload-sourcemaps dist.

Usage analytics (Firebase)

Optional, in the client's own Firebase project. Register the iOS and Android apps with the brand's bundle ID and package, download their config files into the brand folder and reference them:

"services": {
  "firebase": { "android": "firebase/google-services.json", "ios": "firebase/GoogleService-Info.plist" }
}
  • Users are asked once, on first launch. Nothing is collected before they answer or if they decline, and they can change it in Settings → Privacy. Advertising storage and advertising IDs are never enabled.
  • Collected: screen views (by screen, never with ids from the address), sign-in, sign-up, orders placed (amount and currency), parcel bookings and courier applications.
  • Store privacy forms: declare analytics / app interactions, collected only with consent, not linked to the user's identity and not used for tracking.
  • Without the config files, the app has no analytics and never shows the prompt. Adding or removing Firebase is a native change: rebuild the app.

Authentication storage

The JWT access and refresh tokens are kept in the device's secure storage (iOS Keychain / Android Keystore via expo-secure-store), readable after the first unlock so the courier's background location updates keep working. Axios attaches the bearer token and renews it through:

POST /api/v1/users/token/refresh

If the server rejects the refresh token, the app signs the user out. If the device is offline, the user stays signed in and the screen shows a connection error. Signing out revokes the device's refresh token and push token on the server.

When the platform asks for a sign-in code on new devices (Settings → Sign-in security), a password sign-in opens the code screen; with Don't ask again on this device on, the device token the server returns is kept in secure storage, survives signing out and is sent as X-Device-Token on the next password sign-in.

Stripe provider

The app wraps screens in StripeProvider and reads:

EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY

Apple Pay and Google Pay require additional setup in Stripe, Apple Developer, Google Pay, and the native app build configuration.

Accessibility

  • Screen readers: every button, link and input has a role and a name (VoiceOver and TalkBack read them). Option chips, tabs and toggles also say whether they are selected, and screen titles are headings. npm run lint enforces this through eslint/a11y.js: a button with only an icon needs accessibilityLabel, every TextInput needs one, and font scaling can't be turned off. Labels live in the a11y section of the language files.
  • Large text: text follows the system size; containers with text use minHeight, so nothing is cut off. Small icon buttons get a larger touch area (hitSlop).
  • Reduce motion: with the system setting on, animations jump to their end (Reanimated through ReducedMotionConfig, the rest through useReduceMotion()).
  • Contrast: text colours meet WCAG AA (4.5:1) in both themes, for any brand: brand:apply generates every colour slot (constants/Colors.ts re-exports them) and refuses a palette whose text-on-fill pairs don't read. Screens only use slots through useThemeColors(): accent for brand fills with textOnPrimary on them, accentText for brand-coloured text and icons, onDanger on red fills, onMedia and mediaScrim over photos. A new colour is a new slot in tools/brand/palette.mjs, never a value in a screen. The website follows the same rule.

White-label rules

The same code ships to every client, so npm run lint refuses, with the file and line: a colour written in the code (hex, rgb()/hsl() or a named colour), a fontFamily string, a brand's name or domain (also in comments), and reading generated/brand/tokens or the raw font names outside constants/Colors.ts and constants/Fonts.ts (eslint/brand.js). After brand:apply, npm run brand:check confirms the resolved Expo config carries that brand's name, scheme, version, bundle ID, package and EAS project, and that nothing generated for it names another brand. CI runs typecheck, lint, tests and brand:check for the default brand and the acme-eats fixture.

Typeface and languages

Typeface. typography.family in brand.json picks one of the curated Google families in tools/brand/fontCatalog.mjs (DM Sans, Inter, Roboto, Poppins, Montserrat, Cairo, IBM Plex Sans Arabic, …); install its package first with npm install --save-dev @expo-google-fonts/<family>. For the client's own font, use typography.files instead (paths in the brand folder; regular and bold are required, medium, semibold and italic are optional) and typography.name. brand:apply copies the files into the app under fixed names and screens use Fonts.regular … Fonts.bold from constants/Fonts.ts, so changing the family changes every screen.

Scripts. brand:apply reads which letters the font has. Languages it can't draw (DM Sans has no Cyrillic, Arabic, Chinese, Japanese or Korean) are shown in the phone's system font, with the same weights; it prints which ones. A font that can't draw the default language is refused.

Languages. Only locales.enabled are bundled and offered in the app's language picker (hidden when there is one), and locales.default is used when the phone's language isn't one of them. Make the default match the platform's default locale in the backend. Translations can use %{appName} and %{supportEmail}; they are filled from brand.json.

Found a mistake? Tell us.