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.
- Expo account —
eas login, theneas initfromapp/to create the project and writeextra.eas.projectIdintoapp.json. - 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.appexactly. - 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 foreas submit; you can upload the.aabby 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: mergein the live overlay. An omitted key inherits the default overlay's value, which is UAT's. That has already bitten once:CALLS_MEDIA_WS_URLhad 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-webrtcmust 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. reanimatedandworkletsboth emitlibworklets.so, so the build needs apickFirstfor it. That lives inapp.jsonunderexpo-build-propertiesrather than a localgradle.propertiesedit, 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-notificationsis 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 typecheckpasses inapp/ -
google-services.jsonregistered tocom.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