Skip to content

Mobile B2C App Reference (apps/mobile-b2c)

This is the B2C consumer app (apps/mobile-b2c, package ai.kwilo.b2c). For the older B2B companion app (apps/mobile, package com.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-Id
  • CF-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 update OTA.
  • push to stagingstaging channel
  • push to mainproduction 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: mainproduction / production channel, anything else → preview / staging channel.

Requirements

  • EXPO_TOKEN repo 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@v8 with 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.