Android onboarding
You are joining the team that owns the native Android client — the surface that 99% of our users actually see and touch. This page gets you from "git clone" to "first PR merged" in about a week.
If you have not yet read the Onboarding overview, start there. This page assumes you have the 30-minute mental model.
What you are signing up for
The Android app's job, in one paragraph:
Discover call-style recordings that already exist on the device. Let the user explicitly choose what to upload. Run durable uploads via WorkManager that survive app kill and process death. Poll the backend until the call reaches a terminal status. Render a clean transcript with real speaker names and a structured analysis. Never auto-upload anything. Never persist raw audio.
That is the entire job. The interesting design pressure is reliability — uploads must keep going while the user is on the metro with no signal, the app is swiped away, and the phone reboots. Everything else flows from that.
The tech
| Layer | Choice | Notes |
|---|---|---|
| Language | Kotlin 2.0 | Latest stable. We use coroutines aggressively, sealed types, and value class where it helps. |
| UI | Jetpack Compose + Material 3 | No XML layouts. Custom "glass" theme in ui/theme. |
| Nav | Compose Navigation | String routes in ui/navigation/ScryonRoutes.kt. |
| DI | Hilt 2.51 (with KSP) | All singletons live in di/ modules. |
| Networking | Retrofit 2.11 + OkHttp 4.12 + Moshi 1.15 | Interceptors centralised in data/remote/. |
| Async | Coroutines 1.9 + kotlinx-coroutines-play-services | Tasks.await(...) for Firebase. |
| Background | WorkManager 2.9 + Hilt-Work + lifecycle-process | The upload critical path. |
| Auth | Firebase BoM 33.7 (Auth) + Credential Manager 1.3 | Email / Google / Phone providers. |
| Persistence | SharedPreferences per-user-namespaced + on-disk JSON cache | No SQLite, no Room (yet). |
| Build | Gradle Kotlin DSL + version catalog (gradle/libs.versions.toml) |
Day 1 — get it running
1. Prereqs
- Android Studio Hedgehog or newer.
- JDK 17 (Gradle uses JVM 11 target; JDK 17 runs Gradle fine).
- A Firebase project with Email/Password, Google, and Phone sign-in enabled.
- Backend access — local Mac backend (docker compose) for day-to-day dev, or
STAGING_API_KEYfrom Railway for testing without running the backend locally. - A device or emulator on API 26+.
2. Clone and configure
git clone git@github.com:FluxonLabs/scryon-android.git
cd scryon-android
Drop google-services.json into app/ (gitignored). Create local.properties at the repo root:
sdk.dir=/Users/<you>/Library/Android/sdk
# Local backend (get your Mac's IP: ipconfig getifaddr en0)
DEV_BASE_URL=http://192.168.1.xxx:8080/
DEV_API_KEY=dev-local-key
# Railway staging (for testing without running the backend locally)
STAGING_API_KEY=<ask the team>
# Firebase
FIREBASE_WEB_CLIENT_ID=<id>.apps.googleusercontent.com
Full reference: Android · Configuration.
3. Start the backend and install the app
Both repos ship a dev.sh script for one-command startup.
Terminal 1 — backend:
cd ../scryon-backend
./dev.sh # without pyannote (default, no extra key needed)
./dev.sh --pyannote # with pyannote speaker separation
Before the first run, create scryon-backend/.env.local (gitignored):
export LEMONFOX_API_KEY=your-key
export LLM_API_KEY=your-key
Terminal 2 — Android app:
# from scryon-android root
./dev.sh # builds devDebug → installs as "Scryon Dev"
./dev.sh staging # builds stagingDebug → installs as "Scryon Staging"
Make sure DEV_BASE_URL in local.properties matches your Mac's IP first:
ipconfig getifaddr en0 # e.g. 192.168.1.105
# then set: DEV_BASE_URL=http://192.168.1.105:8080/ in local.properties
Flavor → environment mapping:
| Variant | Backend | Use for |
|---|---|---|
devDebug | Local Mac | Day-to-day feature work |
stagingDebug | Railway staging | Integration testing |
prodRelease | Railway prod | Play Store only — never test here |
In Android Studio: Build → Select Build Variant → devDebug, then click Run.
If you skip
google-services.json, the app still builds —AuthGaterenders LoginScreen with a "Firebase not configured" hint. Useful for UI-only changes.
4. End-to-end smoke test
- Sign in (email, Google, or phone).
- The first
GET /api/users/melazily provisions a backend row. - The Calls tab discovers any call-style recordings on the device. If empty, the empty state has an Allow audio access button.
- Tap Transcribe on a recording. Grant
READ_CALL_LOGif you like. - Watch the row appear under Transcribed as Uploading → Queued → Transcribing → Analyzing → Completed.
- Tap the row. The detail screen loads
/api/calls/{id},/transcript, and/analysisin parallel.
If any of those steps fail, Android troubleshooting is your friend.
Day 2 — walk one upload end-to-end with logcat open
The single most useful thing you can do on day 2 is trace one Transcribe tap through to a completed call with logcat open, filtering by CallUploadWorker, OkHttp, and FirebaseIdToken.
The flow you are following:
Every arrow here has a doc page. The most important ones to internalise:
- Upload pipeline — the durable upload + idempotency model.
- Status lifecycle — wire ↔ domain ↔ UI mapping.
- Local stores — all five
SharedPreferencesstores +CallContentCache. - Authentication — Firebase token caching + 401 retry.
Key files to open
| File | Why |
|---|---|
MainActivity.kt | Single Activity; wraps content in AuthGate. |
ScryonApplication.kt | Hilt entry; supplies the WorkerFactory for Hilt-Work. |
data/auth/AuthRepository.kt | Firebase wrappers + sign-in / sign-out semantics. |
data/auth/FirebaseIdTokenProvider.kt | The token cache. Worth reading slowly. |
data/remote/ScryonApi.kt | Every endpoint we consume. |
data/remote/interceptor/* | ApiKeyInterceptor, FirebaseAuthInterceptor, FirebaseAuthAuthenticator. |
data/repository/ScryonRepository.kt | The CallRepository impl — DTO ↔ domain mapping, error mapping, cache. |
work/CallUploadWorker.kt | The foreground service that owns the upload critical path. |
viewmodel/MainShellViewModel.kt | The polling + status merge logic. |
ui/shell/ScryonRoot.kt | The bottom-bar shell. |
Day 3 — privacy + conventions
Read these in order:
- Privacy & security — non-negotiable. The Android-specific bits are the no-raw-audio rule and the per-uid local-store namespacing.
- Android coding conventions in Architecture · Coding conventions.
- Permissions — when each permission is asked, and what to do if denied.
Android-specific gotchas to internalise:
- The UI layer never imports Retrofit, Firebase, or Hilt internals. Repositories return domain models, not DTOs.
- Every local store is namespaced by Firebase
uid. When you add a new store, do this from day one. - Raw audio is never persisted locally. Bytes are streamed from
MediaStoreto OkHttp. Even the voice profile recording goes tocacheDir/voice_profile/and is deleted immediately after upload. - No
LiveData. ViewModels exposeStateFlow; UI usescollectAsStateWithLifecycle().
Day 4 — pick a first PR
Look for issues labelled good first issue on the repo. Good candidates:
- A small Compose tweak (empty state copy, an icon, a spacing fix).
- A new unit test for a
ViewModelor a local store. - Wiring an existing roadmap item — e.g. notification deep-link (
EXTRA_HIGHLIGHT_RECORDING_IDis plumbed; the Calls tab needs the scroll-to logic). - A new entry in
ScryonErrormapping for an HTTP status we don't yet cover.
Stay away from these for your first PR:
CallUploadWorkerand the foreground-service promotion logic.FirebaseIdTokenProviderand the authenticator chain.- Adding a new local store (touches sign-out cleanup in
AuthRepository). - Anything touching the multipart upload shape.
PR checklist (memorise)
- Compose previews added/updated for any UI change.
- No new PII in
Log.d/Log.i/Log.w(filter your logs withadb logcat | grep -i scryonbefore submitting). - If you added a
SharedPreferencesstore, it is namespaced by Firebaseuidand wiped byAuthRepository.signOut(). - If you added a new permission, it is requested at the point of use, not at app launch.
-
./gradlew :app:compileDebugKotlin :app:testDebugUnitTestpasses locally. - Commit message describes the why, not the what.
Day 5 — ship it
Submit, iterate on review, merge. Congratulate yourself.
Week 2 — own a slice
Pick one of these and become the local expert:
| Slice | Where it lives |
|---|---|
| Auth gate + sign-in | ui/auth/, data/auth/, Authentication |
| Upload pipeline | work/, data/repository/ScryonRepository.kt, Upload pipeline |
| Status lifecycle + polling | viewmodel/MainShellViewModel.kt, Status lifecycle |
| Call detail + cache | viewmodel/CallDetailViewModel.kt, data/local/CallContentCache.kt |
| Voice profile | data/voiceprofile/, ui/voiceprofile/, Voice profile setup |
| New-recording notifications | notifications/, Notifications |
| Networking layer | data/remote/, Networking |
"Own a slice" means: read every file in it, draw your own diagram, run the screens by hand, write a one-pager explaining how it works to someone joining next month.
Week 3 — pair with backend
The Android app and the backend meet at a tight REST contract. Spend a day pairing with someone on the backend team. Have them walk you through how a POST /analyze becomes a row, an artifact, and a COMPLETED status. You will find at least one thing the API could do better. File an issue or open a PR (against either repo).
Reading material for the cross-over:
- API overview — every endpoint we consume.
- Call processing pipeline — what happens server-side after
POST /analyzereturns 202.
Week 4 — performance + reliability pass
Take one screen you own and profile it. Use Android Studio's profiler. Measure:
- Cold start time to the Login screen and to the main shell.
- Frame jank when scrolling the Transcribed tab with 50+ rows.
- Memory during a long Compose detail open with a 30-minute transcript.
Pick one win and ship it. We do not have an SLA on these yet — but we should, and you can help define one.
Reference shelf
Bookmark these:
- Android overview — tabs, tech, principles.
- Architecture — layering, directory layout, conventions.
- Upload pipeline — the durable upload critical path.
- Status lifecycle — state machine.
- Networking — interceptors and error mapping.
- Local stores — all SharedPreferences stores + cache.
- Permissions — when each permission is asked.
- Voice profile — opt-in speaker recognition.
- Troubleshooting — symptom → cause → fix.
Common stumbling blocks
| Symptom | Likely cause | Fix |
|---|---|---|
| Build fails with "google-services.json not found" | Missing file in app/ | Drop the file in app/ (gitignored). The plugin is applied conditionally; you can also build without it. |
| Google sign-in button does nothing | Missing FIREBASE_WEB_CLIENT_ID or SHA-1 not registered | Add the web client ID; register your debug SHA-1 in Firebase Console. |
401 — Missing or invalid X-API-Key header | API key for the selected flavor is missing or wrong | Check DEV_API_KEY / STAGING_API_KEY / PROD_API_KEY in local.properties, rebuild (BuildConfig is compile-time). |
| Upload "completes" but row never updates | Pointing at one backend but Firebase project verifies tokens from another | Confirm the backend's Firebase project matches the one in google-services.json. |
| App appears to "close" right after Transcribe | Old build before deferred-foreground fix | Reinstall current build; worker now waits ~4 s before promoting. |
| Hilt-related compile errors after a refactor | Missing @HiltViewModel / @HiltWorker annotation, or a constructor change | Run ./gradlew :app:kspDebugKotlin --rerun-tasks and re-read the error. |
For anything else, Android troubleshooting is more thorough.
What "good" looks like after a month
- You can describe the upload + status lifecycle without notes.
- You have shipped at least 5 PRs that touched non-trivial Compose or coroutines code.
- You have written or improved one piece of documentation in
scryon-docs. - You have profiled at least one screen and shipped a measurable improvement.
- You have an opinion about something we should change in the app's architecture — and you wrote an ADR or filed an issue.
Welcome.