Mobile Release (Android)

The app is com.commstate.app, built from app/ with Expo SDK 55 and shipped through EAS Build. This page is the runbook for getting a build into Google Play, and — just as important — the list of things that are permanent the first time you do them.

What is permanent

Three decisions cannot be undone after the first public release. They are worth reading twice before the first submission.

Decision Value Why it is permanent
Package name com.commstate.app The Play listing is keyed on it. Changing it means a new listing, a new URL, and no upgrade path for installed users.
App signing key Managed by Play App Signing Google holds it. An app signed by a different key cannot update the existing listing.
First versionCode Managed remotely by EAS Play rejects any upload whose versionCode is not higher than one already published. It only ever goes up.

The package name is reverse-DNS of commstate.com, which leaves room for a second app later (com.commstate.warehouse, com.commstate.reseller) without crowding the namespace.

Versioning

eas.json sets cli.appVersionSource: "remote" and autoIncrement: true on the production profile, so EAS owns versionCode and bumps it per build. Do not also set android.versionCode in app.json — two sources of truth for a number Play enforces monotonically is a bad trade.

version in app.json (currently 1.0.0) is the human-facing version name and is yours to bump deliberately.

Two environments, two apps

The app ships against two backends, and the difference is not just a URL.

Local UAT Live
APP_ENV local uat live
Package / bundle id com.commstate.app com.commstate.app com.commstate.app
App name on device Commstate Local Commstate UAT Commstate
API from app/.env api.autocom.wexron.io api.commstate.app
Websocket from app/.env ws.autocom.wexron.io ws.commstate.app
Firebase project commstate-uat commstate-uat commstate-live
Google services file google-services.uat.json google-services.uat.json google-services.live.json
Cluster secret fcm-service-account on endurance fcm-service-account on ranger

app.config.js selects all of it from APP_ENV, which eas.json sets per profile. app.json keeps only what both builds share.

Why this is not just a URL swap

A Firebase configuration is compiled into the binary. Unlike the web frontend — whose API URL is a placeholder substituted at container start — an app cannot be repointed after it is built. So a store build made from the UAT config registers real users' push tokens against the UAT Firebase project, and nothing looks broken: the app installs, logs in, and simply never receives a notification.

Both builds currently share the package name com.commstate.app, so they replace each other on a device. That is deliberate for now: the committed google-services.uat.json is registered for that package, and changing it would break UAT builds until the new package is registered in Firebase.

To run them side by side once live exists, register com.commstate.app.uat in the commstate-uat project, re-download its google-services.json over google-services.uat.json, and change package in app.config.js. Nothing else changes.

app.config.js refuses to build if the google-services file for the selected environment is missing, rather than falling back to the other one. A silent fallback is the bug this arrangement exists to prevent.

Working locally

APP_ENV defaults to uat, and the development EAS profile sets local. Either way endpoints come from app/.env, which is gitignored — a committed localhost URL would be wrong for everyone whose backend is not on that machine. Copy app/.env.example and point it at your API:

cd app && cp .env.example .env && bun run start

Local uses UAT's Firebase project; local work does not warrant a third one.

Setting up the live environment

Done, as of the commit that added this. commstate-live exists, its google-services.json is committed as app/google-services.live.json, and its service account is installed on the live cluster.

For reference, or to rotate the key, the server half is installed like this — the app half alone sends nothing:

kubectl --context commstate-live -n commstate \
  create secret generic fcm-service-account \
  --from-file=firebase-service-account.json=./live-service-account.json \
  --dry-run=client -o yaml | kubectl apply -f -

The UAT cluster already has its own fcm-service-account. The two must never be the same file: a cluster holding the other environment's credentials will send notifications to the wrong project's tokens, which fail silently.

Build profiles

development   APK, dev client         on-device debugging
preview       APK, internal           share a build without Play
production    AAB, autoIncrement      what Play accepts

Play requires an app bundle (.aab), not an APK — the production profile is the only one that produces a submittable artifact.

The steps a human has to do

These need credentials or a legal agreement, so they cannot be automated and should not be handed to an agent.

  1. Expo accounteas login, then eas init from app/ to create the project and write extra.eas.projectId into app.json.
  2. Google Play Console — create the developer account (one-off fee), accept the Developer Distribution Agreement, and create the app entry. Its package must match com.commstate.app exactly.
  3. Play service account — Play Console → Setup → API access → create a service account with Release manager rights, download its JSON key, and point eas.json's submit profile at it. Only needed for eas submit; you can upload the .aab by hand instead.

Firebase

Done. com.commstate.app is registered in the Autocom project (autocom-e859f, project number 162023730340) and app/google-services.json is the console's own file.

It is worth knowing what was wrong, because the failure was invisible. The rebrand's find/replace ran over google-services.json and rewrote two values that are not names but identifiers Google issued:

project_id    autocom-e859f          ->  commstate-e859f        (no such project)
package_name  com.anonymous.autocom  ->  app.commstate.mobile   (never registered)

The project number and app ID survived untouched because they are numeric, so the file still looked plausible. Push was dead from that commit onwards, and nothing said so — FCM has no way to report "this project does not exist" back to the app, so notifications simply never arrived.

The legacy com.anonymous.autocom client is kept in the file, because installs of the old package still exist in the wild.

Never hand-edit this file. Download it from the console. project_id and package_name are not text to be rebranded.

The server side is separate, and is not done by adding the app

google-services.json lets the app receive pushes. Sending them needs a service account key on the server, at FCM_SERVICE_ACCOUNT_PATH.

Without it every push fails with "FCM service account not configured". The failure is quiet in exactly the way the one above was: the app still rings when it is open, because the websocket carries the offer, so only a backgrounded device is affected — and the evidence sits in the push_notification_log table rather than anywhere anyone looks.

Generate it from Project settings → Service accounts → Generate new private key, then store it per environment. UAT and live each have their own commstate-secrets, and the key belongs there alongside DB_PASSWORD — the same value may be used for both, since the key authenticates the sender and device tokens live in each environment's own database.

Take care with behavior: merge in the live overlay. An omitted key inherits the default overlay's value, which is UAT's. That has already bitten once: CALLS_MEDIA_WS_URL had to be set explicitly empty, or every AI call on live opened a socket against the other cluster.

Native dependencies

The app is no longer pure JavaScript over the Expo runtime. Taking calls in the app added three native modules:

package why
react-native-webrtc the peer connection for agent audio
plivo-jssip SIP signalling to the provider's gateway
react-native-incall-manager claims Android's VoIP audio session and routing

Consequences worth knowing before a release:

  • Expo Go cannot run this build. It needs a development build or EAS.
  • @config-plugins/react-native-webrtc must match the SDK — version 14 targets Expo 55; 15 requires 56. An SDK upgrade means bumping it in step.
  • The APK grew from roughly 41 MB to 52 MB, almost entirely libjingle_peerconnection_so.so.
  • reanimated and worklets both emit libworklets.so, so the build needs a pickFirst for it. That lives in app.json under expo-build-properties rather than a local gradle.properties edit, so a clean build gets it.

Building and submitting

From app/:

eas build --platform android --profile production
eas submit --platform android --latest

The submit profile targets the internal track with releaseStatus: draft on purpose. A first submission should not go straight to production — internal testing lets you install the exact artifact Play will serve and confirm it starts, signs in, and receives a push before anyone outside the team sees it. Promote to production from the Play Console once it has been on a real device.

Store listing

Play will not let you publish without all of these. None are in the repo:

  • app icon, 512×512 PNG (the launcher icon is generated from assets/images/icon.png, but Play wants its own upload)
  • feature graphic, 1024×500
  • at least two phone screenshots, and tablet screenshots if you declare tablet support — the app is installed and running on a tablet, so declare it
  • short description (80 chars) and full description (4000)
  • a privacy policy URL that is publicly reachable
  • content rating questionnaire
  • the Data safety form — the app collects account data and uses the microphone and camera, so answer it against what the code actually does rather than from memory

Icons

The launcher icon is generated by prebuild from app.json:

assets/images/icon.png                    full mark, used for iOS and Play
assets/images/android-icon-foreground.png adaptive foreground, transparent
assets/images/android-icon-background.png adaptive background, solid #0F172A
assets/images/android-icon-monochrome.png themed-icon and notification silhouette

Two constraints that are easy to violate and produce no error:

  • Adaptive foreground content must sit inside a circle of radius 338px on a 1024px canvas. Anything outside is cropped by the launcher's mask, differently on every OEM skin.
  • The notification icon must be a silhouette. Android tints status-bar icons and discards colour, so a full-colour image arrives as a solid white square. expo-notifications is pointed at the monochrome asset for this reason.

The current mark is an interim one: an open ring with a state dot, in the product's own #0F172A / #10B981. It replaced the stock Expo template icon, which had design guide lines baked into it. Swap it for real brand artwork when that exists — dropping in new files at those four paths and re-running prebuild is the whole job.

Before the first submission

  • bun run typecheck passes in app/
  • google-services.json registered to com.commstate.app
  • push notification received on a real device
  • sign-in works against production API
  • icon and splash checked on a phone and a tablet
  • privacy policy URL live
  • internal-track install tested before promoting to production