Skip to main content

Mobile App

See also: Local Development · Testing · CI CD and Deploys · Auth and Users · Story Engine

apps/stretched-mobile — an Ionic + Capacitor shell (appId: app.stretched.mobile) around the same component library (@stretched/stretched-components) and the same Firebase project as the web app. Dev server runs on port 4300. Human-facing docs: apps/stretched-mobile/README.md; gotcha list: apps/stretched-mobile/CLAUDE.md.

App flow

Routes in apps/stretched-mobile/src/app/app.routes.ts:

  • /login — the lib's sc-user-login; Google sign-in goes through the GOOGLE_SIGN_IN_FN token, provided natively by core/auth/native-google-sign-in.ts (@capacitor-firebase/authenticationsignInWithCredential in the shell, signInWithPopup in a plain browser). Never call signInWithPopup directly in mobile code — Google blocks OAuth in embedded WebViews. See Auth and Users.
  • authGuard wraps everything else; incomeStoryGuard sends users without a completed income story (users/{uid}.stories.valueOfYourTime[year].isComplete) to /onboarding; encryptionGuard routes to /unlock only when the user doc has encryption meta.
  • On native platforms the Firebase JS SDK auth is created with initializeAuth + indexedDBLocalPersistence (app.config.ts), never getAuth()getAuth()'s popup/redirect resolver hangs on the capacitor:// scheme, authState never emits, and authGuard blanks the whole app. iOS also requires ios/App/App/GoogleService-Info.plist to exist or the Firebase plugins crash the app at launch (a dev-only stand-in is committed; the real one is part of the one-time console setup).
  • Route shape rule: /onboarding, /unlock, /share, /settings are full-screen (no tab bar); /home, /dashboard, and /stories[/:name] live inside the TabsPage shell — keep new flows on the right side of that split.
  • Home derives the translator rate from saved income values (hourlyRateFrom()); encrypted entries decrypt only when EncryptionService is unlocked.
  • Stories read from STORY_CATALOG_SOURCE (bundled VALUE_OF_TIME_STORY today; same provider seam as the web /stories route — swap for a CDN fetch when publishing exists). Shared Firebase Remote Config story flags filter that source on navigation; if the required income story is disabled, incomeStoryGuard bypasses onboarding so the app cannot deadlock. Emulated builds remain local and never call Remote Config. See Story Engine and Feature Flags.

Where logic lives (the facade rule)

Anything both apps need lives in the shared library; mobile's core/services/user-stories.service.ts (UserStoriesService) is only a guest-aware facade over the lib's canonical services (UserStoryEntriesService for story entries, UserService for currency/language preferences). Guest mode (GuestSessionService) is in-memory only and Firebase-free — the reload-wipe is a feature.

Native niceties

Share-to-translate intents (ShareIntentServiceparse-price.ts/home?price=…), deep links / app shortcuts (app.stretched.mobile://translate via DeepLinkService), on-device price OCR (PriceScanService, native-only — guard with isSupported), canvas share cards (core/share/share-card.ts, token-driven), biometric unlock (BiometricUnlockService, password in Keychain/Keystore), app lock overlay (privacy gate only), weekly check-in via local notifications (no FCM — cost rule), Crashlytics guarded by the google-services.json existence check.

Scripts

Web-parity + native, from the repo root (see Tooling and Scripts):

ScriptPurpose
bun run mobile:start:{emulated,dev,prod}Dev server on 4300 → local emulator / deployed dev / prod Firebase
bun run mobile:build:{emulated,dev,prod}Web bundle per environment
bun run mobile:setupOne-time toolchain: JDK 21 + Android SDK, user-local, Win/mac (tools/mobile-setup.ts)
bun run mobile:apk:{dev,prod}Local debug-signed APK (tools/mobile-apk.ts; --install adb-sideloads)
bun run e2e:mobile:emulatedMobile Cypress suite vs a FRESH emulator (see Testing)

Native shells & Firebase per-platform setup

  • android/ and ios/ are Capacitor-generated but committed. Never hand-edit ios/App/CapApp-SPM/Package.swift (CLI-managed, regenerated by cap sync ios).
  • Universal + adaptive (decided 2026-07-22): iOS targets iPhone + iPad (TARGETED_DEVICE_FAMILY = "1,2"), no orientation lock on either platform, and layouts adapt by M3 window size class — --page-max-width (src/styles.scss) widens 36→44→54rem at gt-compact/gt-medium, list screens reflow via the gates (dashboard), and focused screens (home translator, login, unlock) deliberately keep a centered 36rem column. See the app README § "Device targets & window size classes" and Design System and Foundations § Mobile first.
  • Per-platform Firebase app IDs are a human console step (README § one-time setup): register the Android/iOS apps in the Firebase console (stretched-dev first, stretched-2c16a before release), then place google-services.json at apps/stretched-mobile/android/app/ and GoogleService-Info.plist in the Xcode project. On-device Google sign-in silently depends on this.
  • CI native builds are the manual-only "Mobile Build" workflow — see CI CD and Deploys.

Gotchas (the ones that bite)

  • Gradle needs JDK 21 — system Java (26) is too new for Gradle 8.14 and is used by the Firebase emulator; mobile:setup installs the right one user-locally (tools/mobile-machine-paths.ts has per-OS locations).
  • Gradle daemon pipe hang — after a successful build the warm daemon keeps stdout open, so piping gradle output (… | tail) hangs forever after success. Redirect to a file or check for the APK on disk.
  • gradlew's executable bit isn't recorded (created on Windows) — on macOS/CI invoke via sh gradlew ….
  • android/build.gradle forces every library module onto the app's compileSdkVersion — community plugins hardcode older SDKs; keep that block when regenerating.
  • Workspace tsconfig lib < es2021 — no String.replaceAll / Array.at in app source (vitest passes, the Angular build fails).
  • CSS rules from the web app apply verbatim (grid not flex, tokens for everything); Ionic's --ion-* vars are bridged to the design tokens in src/styles.scss — see Design System and Foundations.

Testing

Unit tests via vitest (vite.config.mts; @ionic/* + ionicons must stay in server.deps.inline). E2E: apps/stretched-mobile-e2e, 390×844 viewport; emulator-only specs gate on CYPRESS_EMULATED=1, and the guest journey in app.cy.ts runs everywhere because it's server-free. Visual diffing (mobile:visual) shoots every route at 390×844 (Compact) plus the --expanded manifest rows at 1024×768 (Expanded) — the ≥840px adaptations are diffed. Medium (600–839) and Cypress remain Compact-only; eyeball an iPad simulator before store submission. See Testing and Local Development.