Mobile B2C App Reference (apps/mobile-b2c)¶
This is the B2C consumer app (
apps/mobile-b2c, packageai.kwilo.b2c). For the older B2B companion app (apps/mobile, packagecom.vidyanet.app) see Mobile App Reference: apps/mobile (B2B).
Operational reference: identifiers, env vars, build profiles, and the CI/CD rules. For the product story (why it exists, route map, auth flow, Home design) see the B2C mobile app feature doc.
No secrets on this page. Token values live in the team secret manager and in EAS secrets, never in git and never on the docs site.
Identifiers and versions¶
| Item | Value |
|---|---|
| App name | Kwilo |
| Expo slug | kwilo-b2c |
| Android package | ai.kwilo.b2c |
| iOS bundle id | ai.kwilo.b2c (declared; no Apple credentials configured, nothing ships) |
| App version | 0.1.0 |
| URL scheme | kwilob2c |
| EAS owner | kwiloai |
| EAS project id | cbc62316-9fba-4ce9-870f-90bfb79269cd |
| Updates URL | https://u.expo.dev/cbc62316-9fba-4ce9-870f-90bfb79269cd |
| Runtime version policy | fingerprint |
| Framework | React Native 0.81.5 + Expo SDK 54 (new architecture on) |
| React | 19.1.0 |
| Routing | expo-router 6 (file-based) |
| Styling | NativeWind 4.2 + Tailwind 3.4, @kwilo/ui-native components; tokens from @kwilo/design-tokens |
| State | Zustand 5 + TanStack Query 5 |
| Analytics | posthog-react-native (opt-in; blank key disables) |
Config lives in apps/mobile-b2c/app.json and apps/mobile-b2c/eas.json.
Expo config plugins in use: expo-router, expo-secure-store, @react-native-google-signin/google-signin, expo-build-properties, expo-font, expo-notifications.
Android permissions requested: INTERNET, ACCESS_NETWORK_STATE, POST_NOTIFICATIONS. Edge-to-edge is enabled.
Environment variables¶
Expo inlines any EXPO_PUBLIC_* into the JS bundle at build time. Defaults live in apps/mobile-b2c/constants/config.ts; override per build via .env.local (git-ignored) or EAS secrets.
| Var | Default | Purpose |
|---|---|---|
EXPO_PUBLIC_API_URL |
https://api-staging.kwilo.ai/api/v1 |
Backend base URL |
EXPO_PUBLIC_SITE_URL |
https://kwilo.ai |
Marketing site (terms, privacy links) |
EXPO_PUBLIC_CF_ACCESS_CLIENT_ID |
empty | Cloudflare Access service-token id |
EXPO_PUBLIC_CF_ACCESS_CLIENT_SECRET |
empty | Cloudflare Access service-token secret |
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID |
app.json extra.googleWebClientId |
Google OAuth Web client id. The only one the native SDK reads |
EXPO_PUBLIC_POSTHOG_KEY |
empty | PostHog analytics + error capture. Opt-in; blank disables it |
EXPO_PUBLIC_POSTHOG_HOST |
https://t.kwilo.ai |
PostHog managed proxy (same as apps/web) |
These values ship in the binary
Every EXPO_PUBLIC_* value is inlined into the JS bundle and is extractable from any distributed APK/AAB. Ship the CF Access service token only in internal builds pointed at staging. Never in a public store build. Keep real values in .env.local or EAS secrets. Note that .gitignore here only excludes .env*.local, so a plain .env would be committed.
Cloudflare Access (staging only)¶
api-staging.kwilo.ai sits behind Cloudflare Access and 302-redirects any session-less request to interactive SSO, which a native client cannot complete. The app therefore sends a Service Auth token as headers on every request (services/api.ts):
CF-Access-Client-IdCF-Access-Client-Secret
The matching Cloudflare Access app policy on api-staging must be set to Service Auth for the token to be accepted. Production (api.kwilo.ai) is not gated, so both vars stay empty in the production profile.
Google Sign-In credentials¶
The native @react-native-google-signin SDK authenticates against the Web OAuth client. The returned id_token's aud is that web client, which the backend already trusts as oauth_google_client_id, so no per-Android-client allowlist is needed.
An Android OAuth client (package ai.kwilo.b2c + the build's signing SHA-1) must still exist in GCP so Google recognizes the app. It is matched by signature and never passed in code.
Gotcha that costs an afternoon: every keystore has a different SHA-1 (the EAS build keystore, Play App Signing, and each dev's local debug keystore all differ). A build whose SHA-1 is not registered fails with DEVELOPER_ERROR. Register each one in the shared GCP project:
eas credentials -p android # the EAS build keystore's SHA-1
keytool -keystore ~/.android/debug.keystore -list -v \
-alias androiddebugkey -storepass android # a local debug keystore's SHA-1
EAS build profiles¶
From apps/mobile-b2c/eas.json (EAS CLI >= 10.9.4, appVersionSource: local):
| Profile | Channel | Distribution | Android artifact | API URL |
|---|---|---|---|---|
development |
development |
internal | APK (dev client) | https://api-staging.kwilo.ai/api/v1 |
preview |
staging |
internal | APK | https://api-staging.kwilo.ai/api/v1 |
production |
production |
internal | AAB (app-bundle) | https://api.kwilo.ai/api/v1 |
Note that production is still distribution: internal. There is no Play Store submission configured yet (submit.production is empty).
Commands¶
Run from apps/mobile-b2c.
pnpm start # expo start
pnpm android # expo run:android
pnpm ios # expo run:ios
pnpm typecheck # tsc --noEmit
pnpm test # jest
pnpm format:check # prettier
pnpm dev:build # eas build --profile development --platform android
pnpm build:preview # eas build --profile preview --platform android
pnpm build:prod # eas build --profile production --platform all
pnpm update:staging # eas update --branch staging
pnpm update:prod # eas update --branch production
CI/CD: OTA is automatic, native builds are manual¶
Workflow: .github/workflows/mobile-eas.yml (name: Mobile B2C EAS). Triggers on push to staging or main when apps/mobile-b2c/**, packages/ui-native/**, or the workflow file itself changes, plus workflow_dispatch.
Native builds never trigger automatically. This is on purpose.
A native EAS build consumes the Expo plan's monthly Android build quota. That quota was exhausted on 2026-07-13 and resets 2026-08-01. Nothing in CI will spend a build for you.
To cut a build: GitHub → Actions → "Mobile B2C EAS" → Run workflow, then pick a profile (development / preview / production) and a platform (android / ios / all). iOS is manual regardless, since no Apple credentials are configured.
The build link is posted to the run's job summary.
The fingerprint rule (build vs OTA)¶
app.json sets runtimeVersion.policy: "fingerprint". On a push to staging or main, the workflow generates the current native fingerprint (eas fingerprint:generate) and compares it against the last finished build on the matching profile (eas build:list --status finished --limit 1):
- Fingerprint unchanged (a JS-only change) → the runtime on installed devices still matches, so the workflow publishes a free
eas updateOTA. - push to
staging→ staging channel - push to
main→ production channel - Fingerprint changed (new native module, SDK bump, config-plugin change) → an OTA would be invisible on the installed runtime, so the workflow publishes nothing and prints a "Native build required" notice in its job summary. Go cut a build with the Run workflow button.
Channel to build-profile mapping lives in eas.json. Branch to profile mapping is resolved in the workflow: main → production / production channel, anything else → preview / staging channel.
Requirements¶
EXPO_TOKENrepo secret. Create at expo.dev → Account → Access Tokens, then add under GitHub repo Settings → Secrets and variables → Actions.- The workflow runs via
expo/expo-github-action@v8with pnpm and Node 20.
The old mobile-eas-update.yml was absorbed into mobile-eas.yml and deleted. Do not re-add it: two workflows on the same push means a double trigger.
Building without the EAS cloud quota (local builds)¶
EAS cloud builds consume the Expo plan's monthly Android build allowance. When it runs out (it did on 2026-07-13, resetting 2026-08-01), you can still produce a real, distributable APK by building locally. A local build does not touch the cloud quota, and it still pulls the EAS-managed keystore, so the signing SHA-1 stays the one registered with Google and Sign-In keeps working. Generating a fresh local keystore instead would break Google Sign-In with DEVELOPER_ERROR.
Prerequisites on the build machine: the Android SDK, and a JDK. On macOS the Android Studio JBR is usually the only JDK present.
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
export ANDROID_HOME="$HOME/Library/Android/sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH"
cd apps/mobile-b2c
eas build --local --platform android --profile preview \
--non-interactive --output ~/Desktop/kwilo-b2c-preview.apk
The preview profile points the build at staging and puts it on the staging OTA channel, so an installed local build still self-pulls OTA updates. Verify what you produced before handing it out:
"$ANDROID_HOME"/build-tools/*/aapt2 dump badging <apk> | head -2 # package + version
"$ANDROID_HOME"/build-tools/*/apksigner verify --print-certs <apk> # signing SHA-1
Distributing to testers: Firebase App Distribution¶
Live since 2026-07-14. App Distribution costs nothing on the Spark plan, and it has no build quota, so a local build plus a Firebase upload is a free path from commit to a tester's phone.
Where it lives¶
Everything runs in the kwilo-b2c-app Firebase project (project number 808534359428), not in vidyanet-staging.
An earlier draft of this page said to add Firebase to vidyanet-staging. Don't. Two reasons. kwilo-b2c-app already existed as a Firebase project with nothing registered in it, so there was no work to save. And vidyanet-staging is shared with the setuai project, while adding Firebase to a GCP project is not cleanly reversible, so the blast radius was never worth it.
| Thing | Value |
|---|---|
| Firebase project | kwilo-b2c-app (808534359428) |
| Android app | ai.kwilo.b2c |
| Firebase App ID | 1:808534359428:android:7529d751bc84e93217f1d1 |
| Tester group | devs |
| CI identity | vidyanet-backend-sa@vidyanet-staging.iam.gserviceaccount.com, holding roles/firebaseappdistro.admin on kwilo-b2c-app |
The service account lives in one project and holds a role on another. GCP is fine with that, because IAM bindings attach to the resource, not to the identity's home project. It means CI needed no new WIF binding and no new repo secret.
CI publishes automatically¶
.github/workflows/mobile-eas.yml already builds the APK and uploads it to GCS. The distribute step at the end is gated on the FIREBASE_APP_ID repository variable, which is now set. That variable is the on switch. Clear it and distribution stops while the rest of the pipeline keeps working.
Auth is Workload Identity Federation, the same credentials the GCS upload uses. firebase-tools reads GOOGLE_APPLICATION_CREDENTIALS, which google-github-actions/auth writes. There is no service-account key anywhere in this path, and there cannot be: the org enforces constraints/iam.disableServiceAccountKeyCreation.
Publishing by hand¶
npm i -g firebase-tools
firebase login
firebase appdistribution:distribute ~/Desktop/kwilo-b2c-preview.apk \
--app 1:808534359428:android:7529d751bc84e93217f1d1 \
--groups "devs" \
--release-notes "Notifications: local reminders, streak nudges, in-app banner."
Testers¶
Testers get an email invite, install the Firebase App Tester app once, and pull the APK from there. Later releases land in the same place.
Adding someone through the API is two calls, not one. testers:batchAdd registers them on the project, then groups/devs:batchJoin puts them in the group. Calling batchJoin on its own returns a 404 that says nothing useful.
Invite links are the other option, and they're the right one if you want to hand the build to someone without collecting their email first. Anyone who opens the link enrolls themselves. Treat the link as sensitive: the staging APK carries the Cloudflare Access service token inlined in its JS bundle, so passing the link around is passing out a working credential for api-staging.
The limits are real, despite what the earlier draft of this page claimed:
| Limit | Value |
|---|---|
| Testers per project | 500 (no-cost increase on request) |
| Testers per group | 200 (no-cost increase on request) |
| Releases per app | 1,000, oldest auto-deleted past that |
| Release lifetime | 150 days |
| Invitation validity | 30 days to accept |
Remote push (FCM)¶
Local notifications (study reminders, streak nudges) work with no credentials at all. Remote push does not, until FCM is wired.
Expo's FCM V1 relay wants a service-account JSON key, which you'd normally get from Firebase console → Project settings → Service accounts → Generate new private key, then upload with eas credentials → Android → FCM V1.
Expect that to be blocked. The org enforces constraints/iam.disableServiceAccountKeyCreation, and this flow is exactly a service-account key creation. Nobody has tried it yet, so the failure mode is unconfirmed. If it is blocked, the options are an org policy exception scoped to this one project, or talking to FCM directly from the backend instead of relaying through the Expo Push Service.
Until push is wired, the device still registers an Expo push token and the backend still stores it, and nothing is ever delivered. That silence is expected, not a bug.
Android push is free. iOS later needs an Apple Developer membership for APNs, which you need for the App Store anyway.
Where Expo still fits¶
Moving distribution to Firebase does not replace Expo. The split:
| Concern | Owner | Cost |
|---|---|---|
| App framework (expo-router, expo modules, SDK) | Expo | free |
| Native build | Expo (eas build, cloud or --local) |
cloud is quota-limited; local is free |
| OTA updates for JS-only changes | Expo (EAS Update) | free, and unaffected by the build quota |
| Signing keystore + credentials | Expo (EAS-managed) | free |
| Distributing an APK to testers | Firebase App Distribution | free |
| Remote push transport | Expo Push → FCM (or direct FCM) | free on Android |
Only two things move off Expo: reliance on the cloud build quota (build locally instead) and internal distribution (Firebase instead). Everything else, most importantly EAS Update, keeps working exactly as before. A locally built APK still carries its channel, so OTA updates still reach it.
Troubleshooting¶
| Symptom | Cause / fix |
|---|---|
CI build dies at expo doctor with Error: kill ESRCH |
A race in eas-cli-local-build-plugin. It sets a timeout to kill the doctor subprocess, the subprocess exits first, and the kill throws on a dead PID. Flaky, not deterministic: the same commit and the same eas-cli version build fine on a retry. Re-run the job. Do not pin eas-cli over this, the version is not the cause. |
| A failed local build prints the signing keystore into the run log | Known and unfixed. On failure the EAS local-build plugin dumps its whole job payload, which contains keystore.dataBase64, keystorePassword, keyAlias, and keyPassword in base64. Anyone with read access to the repo can decode it and sign an APK as Kwilo. See the warning below. |
Google Sign-In fails with DEVELOPER_ERROR |
The build's signing SHA-1 is not registered as an Android OAuth client in GCP. See the credentials section above. |
| Every staging API call 302s to an SSO page | CF Access service-token headers missing (empty EXPO_PUBLIC_CF_ACCESS_*), or the CF app policy on api-staging is not set to Service Auth. |
Cold launch lands on /login instead of /welcome |
The 401 interceptor in services/api.ts is redirecting on the boot /auth/me probe. It must reject that probe instead. |
| A merged JS change never reaches the device | Check the workflow run: if the fingerprint changed, no OTA was published and a native build is required. |
| Gesture handlers never fire | react-native-gesture-handler does not receive touches under Expo 54's new architecture in this app. Build gestures on core PanResponder + Animated. |
| An animation at the router root does not run | Reanimated does not run for components at the expo-router root (for example the BrandIntro splash). Use core RN Animated there. |
| Metro cache weirdness | npx expo start --clear |
Open risk: the keystore leaks into CI logs on a failed build¶
When eas build --local fails, the plugin echoes the base64 job payload it was invoked with, and that payload carries the EAS-managed signing keystore plus all three of its passwords. Run 29364337792 on 2026-07-14 is one example. Any failed local build produces another.
What this means in practice: anyone who can read the Actions logs on kwiloai_webapp can extract the keystore and sign an APK that Android and Google Sign-In will both accept as genuine Kwilo. The repo is private, so that's org members rather than the public, which is the only reason this is a risk and not an incident.
Rotating the keystore is not a casual fix. The signing SHA-1 is what the Android OAuth client is pinned to, so a new keystore breaks Google Sign-In on every distributed build until the new SHA-1 is registered, and it breaks the upgrade path for anyone who already has the app installed.
Nobody has picked this up yet. The cheap mitigations, in the order I'd try them:
- Stop echoing the payload. Pipe the eas build output through a filter that drops the base64 blob before it reaches the log, so only the failure reason survives.
- Restrict who can read Actions logs on the repo.
- Rotate the keystore and re-register the SHA-1, accepting the reinstall, if a leak is ever suspected.