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
- The user taps Continue with Google or Continue with Apple.
AuthContext.login()mints a PKCE verifier (expo-cryptoSHA-256,react-native-get-random-valuesfor randomness) and a state token.expo-web-browser.openAuthSessionAsyncopens the Cognito Hosted UI inside an SFAuthenticationSession (iOS) / Custom Tab (Android).- Cognito redirects to
tailspend://login?code=…&state=…. Expo's auth session resolves with that URL. - The state is verified against what we stashed; the code is exchanged for tokens via
@repo/auth-core.exchangeCodeForTokens. - Tokens are persisted via
expo-secure-store(keychain / Keystore-backed). - The shared
<ApiProvider>callsgetIdToken()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:
- Add an Expo access token as the
EXPO_TOKENGitHub Actions repository secret. - Configure the app signing and store submission credentials in EAS.
- For non-interactive Android submission, configure a Google Play service
account key in EAS (already done). Local and CI releases both use it; don't
set a local
serviceAccountKeyPathineas.json— seedocs/mobile-release.md.
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:
EXPO_PUBLIC_API_BASE_URLEXPO_PUBLIC_COGNITO_DOMAINEXPO_PUBLIC_COGNITO_CLIENT_ID
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
- Bundle IDs are
studio.kinesis.tailspendon both platforms inapp.base.json. Confirm they match what you register in App Store Connect and the Google Play Console. - Replace the scaffold icons under
apps/mobile/assets/images/(icon, adaptive icon, splash, favicon) with TailSpend artwork. - The
nameinapp.base.jsonis shown on the home screen; current value isTailSpend.
3. Push notifications
Wired with expo-notifications (issue #625; see notifications.md). Before push works on a build:
- Upload APNs (iOS) and FCM v1 (Android) credentials to EAS:
eas credentials. expo-notificationsis a native module, so it needs a new store build; it can't ship as an OTA update.- Sending is switched off per stage until
/tailspend/<stage>/notifications/push-enabledistrue.
4. Deep links
The native app declares iOS Universal Links and verified Android App Links for
https://tailspend.kinesis.studio. Supported paths are:
/groups/{groupId}/join?invite=…and its/expenses/{expenseId}preview./users/{usernameOrId}and legacy/u/{handle}profile links.
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
- Privacy policy URL (required for Google Sign-In).
- Screenshots at the required sizes for each store.
- App description, support URL, marketing URL.
- App Store Connect and Play Console listings created with matching bundle IDs.
6. Testing
The mobile app currently has no test suite. Recommended starting point:
- Unit tests for
auth/crypto.tsandauth/tokenStorage.tswithjest-expo. - Component tests for the home screen states (loading / signed-in / signed-out) using
@testing-library/react-native.
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:
- Group list with create-group flow → reuses
useCreateGroup. - Group detail with expense list and create-expense flow → reuses
useExpenses,useCreateExpense. - Expense details, person pills, inline
@mentions and editing behavior follow the shared expense UI guide. - Public invite preview + join-request submission for non-members.
Every one of these uses the same shared hook the web app uses; the mobile work is purely UI.