TailSpend Docs

Mobile app

The TailSpend mobile app is an Expo (React Native) app that ships to iOS and Android via EAS Build. It lives at apps/mobile. Architecture context is in architecture.md; the API contract it talks to is in api.md.

The app shares its auth flow and data-fetching layer with the web SPA — only the platform-specific edges (token storage, redirect mechanism, SHA-256 backend) differ.

Config files

Expo loads app.config.js, which spreads the static values from app.base.json. The static file is deliberately not called app.json: Expo auto-loads a file with that name as well as the dynamic config, and expo doctor then fails with "You have an app.json file in your project, but your app.config.js is not using the values from it" — even when it demonstrably is. Renaming it leaves exactly one config entry point.

app.config.js exists because Maestro builds must disable OTA updates so UI tests always run the embedded bundle rather than a downloaded one. Everything else lives in app.base.json, where it can be diffed and read by tooling (scripts/release.mjs and the pipeline's native-branding check both parse it).

Don't rename app.base.json back to app.json.

Layout

apps/mobile/
  app.base.json         # Expo config values (bundle IDs, scheme, plugins)
  app.config.js         # The config Expo actually loads; spreads app.base.json
  eas.json              # EAS Build profiles (development / preview / production)
  metro.config.js       # Monorepo-aware Metro (Expo's defaults, plus a
                        #   patched URL-decoder resolution)
  src/
    app/                # Expo Router file-based routes (tabs)
    auth/
      AuthContext.tsx   # Mobile auth provider — uses
                        #   expo-web-browser.openAuthSessionAsync
      config.ts         # EXPO_PUBLIC_* env reads + Cognito config
      crypto.ts         # PkceCryptoAdapter using expo-crypto +
                        #   react-native-get-random-values
      tokenStorage.ts   # TokenStorage adapter — SecureStore on native,
                        #   AsyncStorage on web
    api/
      MobileApiProvider.tsx  # Bridges useAuth().getIdToken into
                             #   <ApiProvider>

Shared packages

Package Responsibility Where its platform glue lives
@repo/auth-core Cognito Hosted UI helpers — PKCE, authorize URL, token exchange. Web: apps/web/src/auth/*. Native: apps/mobile/src/auth/*.
@repo/api-hooks TanStack Query hooks, typed API client, <ApiProvider>. Web: WebApiProvider.tsx. Native: MobileApiProvider.tsx.
@repo/api-client Generated openapi-fetch client + paths types. None — pure types, used by both.

The Cognito tokens, getIdToken refresh logic, and every TanStack Query hook live in shared packages. If you change auth or fetching behaviour, change it once.

Auth flow

  1. The user taps Continue with Google or Continue with Apple.
  2. AuthContext.login() mints a PKCE verifier (expo-crypto SHA-256, react-native-get-random-values for randomness) and a state token.
  3. expo-web-browser.openAuthSessionAsync opens the Cognito Hosted UI inside an SFAuthenticationSession (iOS) / Custom Tab (Android).
  4. Cognito redirects to tailspend://login?code=…&state=…. Expo's auth session resolves with that URL.
  5. The state is verified against what we stashed; the code is exchanged for tokens via @repo/auth-core.exchangeCodeForTokens.
  6. Tokens are persisted via expo-secure-store (keychain / Keystore-backed).
  7. The shared <ApiProvider> calls getIdToken() per request, refreshing within 60 s of expiry.

Sign-out clears the secure store and best-effort fires the Cognito /logout endpoint to drop the Hosted UI session cookie so the next sign-in re-prompts.

Environment variables

EXPO_PUBLIC_* vars are inlined into the JS bundle at build time. The app reads them by literal key (Expo's static analyzer rejects dynamic access):

Var Purpose
EXPO_PUBLIC_API_BASE_URL TailSpend HTTP API base URL.
EXPO_PUBLIC_COGNITO_DOMAIN Cognito Hosted UI domain.
EXPO_PUBLIC_COGNITO_CLIENT_ID Cognito user-pool client ID for the mobile app.
EXPO_PUBLIC_NEW_RELIC_IOS_APP_TOKEN Optional. New Relic iOS app token for this environment; unset ⇒ monitoring off.
EXPO_PUBLIC_NEW_RELIC_ANDROID_APP_TOKEN Optional. New Relic Android app token for this environment.
NEWRELIC_USER_API_KEY Optional, sensitive (not EXPO_PUBLIC_). Uploads source maps, dSYMs and mapping files at build/update time.

New Relic monitoring needs a development or store build; it is inert in Expo Go. See Observability.

Local development reads these values from apps/mobile/.env. For builds, set them via eas env push. The production values and the dedicated mobile client output are documented in apps/infra/README.md.

Dev workflow

# Install once at the repo root — npm workspaces share node_modules.
npm install

# Start Metro. Press i / a to launch a simulator, or scan the QR with Expo
# Go on a phone.
npm run dev --workspace=mobile

# Type-check just the mobile app.
npm run check-types --workspace=mobile

# Lint just the mobile app.
npm run lint --workspace=mobile

turbo dev from the repo root will start Metro alongside the web dev server.

EAS Build

The eas CLI must run from inside apps/mobile (it ignores npm's --workspace flag). The npm run … scripts use npm's workspace flag and can run from the repo root.

# One-time:
npx eas login
(cd apps/mobile && npx eas init)

# Internal preview builds (TestFlight on iOS, signed APK on Android).
npm run build:ios:preview      --workspace=mobile
npm run build:android:preview  --workspace=mobile

# Production build only, no submit. autoIncrement bumps build numbers.
npm run build:ios:production      --workspace=mobile
npm run build:android:production  --workspace=mobile

# Submit a production build that is already uploaded.
npm run submit:ios     --workspace=mobile
npm run submit:android --workspace=mobile

Cutting a release

Don't drive a release out of the individual commands above — a build and its submit can get out of step, and the pipeline's native-branding tag needs moving too. Use the one command, which preflights, builds both platforms, auto-submits and records the release:

cd apps/mobile
npm run release:production:check   # preflight only
npm run release:production         # the real thing

docs/mobile-release.md has the one-time setup (EAS login, Play service account key, Apple credentials), the flags, and what each preflight check is protecting against.

EAS auto-detects npm workspaces at the repo root and runs npm install from there before bundling. The .easignore in apps/mobile keeps node_modules and platform build caches out of the upload tarball.

GitHub Actions

The manually dispatched EAS Build workflow creates a development, preview, or production build for either platform. Each build profile explicitly uses the EAS environment with the same name.

Enabling the auto_submit input submits the build to App Store Connect or Google Play automatically once it completes — EAS Build hands the finished build straight to EAS Submit server-side (using the production submission profile), so there is no second workflow and no build ID to copy by hand. The production iOS submit profile contains TailSpend's App Store Connect app ID.

The final native-release gate reads each returned build ID again in a fresh EAS CLI process before requiring every selected platform to be FINISHED. This avoids cached pre-build status in auto-submit JSON incorrectly failing a successful release. Missing IDs, lookup errors and unfinished/failed builds still block the gate. A successful store upload does not mean public approval.

Before running the workflow:

Next steps before shipping

These are the gaps between today's scaffold and a release-ready app. Each is small in isolation; together they're the path to TestFlight + Play Store internal testing.

1. EAS project + env

The eas CLI doesn't accept npm's --workspace flag, so run it from inside apps/mobile:

cd apps/mobile
npx eas init       # creates the project in EAS
npx eas env push   # uploads .env to EAS for builds

Recommended secrets:

Set them per environment (development, preview, production) so a preview build can point at a beta API while production points at prod. Use the MobileUserPoolClientId output from the stage's Auth stack for EXPO_PUBLIC_COGNITO_CLIENT_ID. This dedicated public PKCE client supports both Google and Apple and allows the app's tailspend://login callback plus http://localhost:8081/login for Expo web development.

2. App identity

3. Push notifications

Wired with expo-notifications (issue #625; see notifications.md). Before push works on a build:

The native app declares iOS Universal Links and verified Android App Links for https://tailspend.kinesis.studio. Supported paths are:

Expo Router already handles the invite routes and preserves the invite query parameter. src/app/+native-intent.ts maps legacy /u/ links to /users/ on both cold and warm opens. The tailspend:// scheme remains available, including the separate tailspend://login OAuth callback. Login, admin, downloads and marketing paths are not claimed as verified app links. Only the production host is claimed: a production install must not consume a beta/dev invite against its production API.

Vite emits /.well-known/apple-app-site-association and /.well-known/assetlinks.json into the web bundle. WebStack uploads both with Content-Type: application/json and Cache-Control: no-cache before invalidating CloudFront. See web-hosting.md for the signing configuration and deployment gate.

Release order (coordinate with #664): configure the signing identities, deploy the web verification endpoints, then ship a new native store build. Associated Domains entitlements and Android intent filters cannot ship via OTA. An older installed app keeps opening HTTPS links in the browser; devices without the app continue to use the web join/profile flow.

Before accepting a release, verify both endpoints return JSON with HTTP 200 over HTTPS and no redirect. Install the newly signed iOS/Android builds, then tap an invite containing a real token and both profile URL forms from another app (Messages/Mail), with TailSpend closed and already running. Check the invite preview and profile destination; also check browser fallback without the app. On Android, adb shell pm verify-app-links --re-verify studio.kinesis.tailspend followed by adb shell pm get-app-links studio.kinesis.tailspend should show the production host as verified. Do not specify the package in a launch intent when testing verification: that would force the app open even if verification failed.

5. App Store / Play Store assets

6. Testing

The mobile app currently has no test suite. Recommended starting point:

The shared packages already exercise their logic via the web app's vitest suite, so we don't need to duplicate auth-core / api-hooks tests on the native side.

7. Hooks the home screen still needs

apps/mobile/src/app/index.tsx is a smoke screen that calls useMe and useMyGroups. Once the auth client is provisioned end-to-end, port the rest of the web app screen-by-screen:

Every one of these uses the same shared hook the web app uses; the mobile work is purely UI.

View Markdown source