Skip to content

AstraCall Android — Build Plan (replace liblinphone, calls only)

Decision (Hari, 2026-07-04): same call as iOS — no Belledonne commercial licence. Replace the GPLv3 linphone-android fork + linphone-sdk-android with our own phone-side engine talking to our existing Asterisk. This is the Android sibling of astracall-engine-plan.md; the server work is already done and live (WSS transport + webrtc=yes endpoints, built for iOS Phase A). The UAT call rows (ios-uat-test-plan.md) are the acceptance contract for both platforms.

Related: astracall-engine-plan.md (iOS spec, shared architecture) · astracall-execution-blueprint.md (every bug already solved once) · voip-push-wake.md (wake design, transport-agnostic) · android-status.md (current fork state).


1. Goal & non-goals

Goal: the Android app registers to our Asterisk, rings reliably in every app/phone state (foreground, background, killed, Doze, locked), connects fast with 2-way audio that survives WiFi↔LTE, hangs up cleanly — zero GPL code, Play-Store-submittable.

Non-goals (v1): chat/conversations, meetings, conference UI, video, in-app recordings browser, multi-account, contacts editing (device directory read-only via the system contacts app, as today). Single active call + call-waiting. Everything not "call" is already hidden in the current app and stays out.

In scope beyond calls (unlike iOS): the whole Astradial product ships in this one Android app — Pulse analytics, Tickets, org sign-in + line picker, One UI dialer/recents/contacts/settings. These are engine-independent and carry over as-is (see §5 inventory).

2. Licensing outcome

Component Licence Status
AstraSIP-Kotlin (SIP-over-WSS) ours, proprietary written by us, ~3–5k lines
Google WebRTC Android (org.webrtc, io.github.webrtc-sdk:android) BSD-3 media; attribution in About
App UI (our One UI screens + astradial.*) ours move as-is
Engine glue (CoreContext, call viewmodels, Telecom) GPLv3-derived (upstream) rewritten from scratch against the behavioral spec
linphone-android fork + linphone-sdk-android AAR GPLv3 gone at cutover

Clean-room rule (same as iOS): knowledge and our design docs carry over; no Belledonne source is copied into the new repo. Files we authored from scratch (org.linphone.astradial.*, every oneui_*/astradial_* layout, OneUiSettingsFragment, OneUiBottomBar, our drawables/strings/themes) are ours to move. Anything derived from upstream (core/CoreContext, ui/call/**, CurrentCallViewModel, Telecom glue, history/contacts viewmodels) gets rewritten against the same behaviour (this wiki + UAT rows). Git history in astradial-android proves authorship per file.

3. Architecture (identical to the validated iOS design)

┌──────────────────────────── Android phone ───────────────────────────┐
│  App UI (ours, One UI): sign-in → pick extension → keypad · recents · │
│  contacts · Pulse · Tickets · settings · our call screens             │
│───────────────────────────────────────────────────────────────────────│
│  AstraCall viewmodels (ours): expose the SAME LiveData surface our     │
│  existing call/keypad screens already bind to (displayedName, isMuted, │
│  isPaused, callDuration, isSpeakerEnabled, DTMF, audio-route list…)    │
│───────────────────────────────────────────────────────────────────────│
│  AstraTelecom (ours): self-managed ConnectionService + mic FGS +       │
│  FCM wake-v2 client (push-ack POST → placeholder call → WSS attach;    │
│  single call object → the liblinphone push-phantom bug class is        │
│  structurally impossible)                                              │
│───────────────────────────────────────────────────────────────────────│
│  AstraSIP-Kotlin (ours, JVM lib, unit-tested): SIP over WSS via OkHttp │
│  WebSocket (RFC 7118 `sip` subprotocol) — REGISTER/INVITE/ACK/BYE/     │
│  CANCEL/re-INVITE(hold)/OPTIONS · digest auth (MD5+SHA-256) · dialog   │
│  & transaction state machines · backoff reconnect · refresh on         │
│  wake/foreground/network-change (ConnectivityManager)                  │
│───────────────────────────────────────────────────────────────────────│
│  Google WebRTC (BSD): audio-only PeerConnection · Opus/G711 · AEC ·    │
│  jitter · DTLS-SRTP · ICE · DTMF (RtpSender, RFC 4733)                 │
└───────────────┬───────────────────────────────────────────────────────┘
                │ WSS :8089 (SIP)  +  SRTP/ICE (media)
┌───────────────▼──────────── Asterisk (UNCHANGED — already WSS-ready) ──┐
│ transport-wss + endpoint webrtc=yes · wake endpoint + push-ack ·       │
│ dialplan/queues/IVR/failover · CDR · MixMonitor · expiry/qualify       │
└────────────────────────────────────────────────────────────────────────┘

Same free wins as iOS: WSS = reliable transport (no SIP retransmit timers, the TCP/TLS connection is the NAT pinhole); our push + our call objects (no phantom-call bug class); one server (implement the subset both ends speak, not the RFC universe). The server is already converted — Android just points at transport=ws and reuses it.

4. Component specs (deltas from the iOS spec)

4.1 Server — transport done; FCM sender is a stub to finish.

WSS transport + webrtc=yes endpoints are live; Android test extension gets the same sip_port='wss' flavor (UDP fleet untouched, per-user migration). The wake gateway is already platform-aware: POST /users/voip-push-token stores voip_push_platform, and the send branches android → fcmService.sendDataPush, ios → APNs (server server.js). One server task remains for Android wake: services/fcmService.sendDataPush is a stub (returns not-configured, gateway falls back to cached SIP state) until FCM_SERVICE_ACCOUNT_JSON is set and the FCM HTTP v1 send is wired — done in Phase C alongside the app's FCM receiver. Payload is { caller, 'push-id' }, identical to iOS.

4.2 AstraSIP-Kotlin (Gradle library module, ours)

  • Pure-JVM (no Android deps) so its JUnit tests run with no device — the Phase A fast gate. Message parser/serializer for the subset (strict out, tolerant in); corpus recorded from our Asterisk (reuse the iOS AstraSIP corpus — same server).
  • INVITE client+server transactions on reliable transport; CSeq/route/tag; digest auth on 401/407; REGISTER refresh at expiry/2 honoring the Settings picker (30s–5m, server floor/cap).
  • Transport: OkHttp WebSocket (Sec-WebSocket-Protocol: sip), ping keepalive, exponential-backoff reconnect, refresh on wake/foreground/ network-path change.

4.3 Media (Google WebRTC Android)

  • Audio-only PeerConnection per call; SDP glued into INVITE/200/ACK (and re-INVITE for hold: a=sendonly / MOH from Asterisk).
  • Audio discipline (the Android version of the iOS CallKit-audio risk): WebRTC JavaAudioDeviceModule + AudioManager MODE_IN_COMMUNICATION; activate media only once the ConnectionService audio route is up.
  • DTMF: RtpSender.dtmf() (RFC 4733), matches endpoint dtmf_mode=rfc4733.
  • Dependency: pin io.github.webrtc-sdk:android (checksum); build-from-source is the later hardening option.

4.4 AstraTelecom + wake-v2 client

  • FCM token registration (shared, platform-aware endpoint — see voip-push-wake.md): on FCM onNewToken and after a SIP line attaches, POST /users/voip-push-token with { asterisk_endpoint, token, environment: dev|prod, bundle_id: <package>, platform: "android" }. platform:"android" routes the server to FCM. Endpoint = the attached SIP username (read from the engine, not a stale account store — this was an iOS bugfix). Token presence flips → org dialplan regen (the wake CURL).
  • Self-managed ConnectionService (not managed — we own the UI): incoming Connection + our One UI full-screen incoming screen; mic foreground service.
  • FirebaseMessagingService receives the high-priority data message { caller, 'push-id' } → ① POST /users/voip-push-ack {push_id} first (unauth; the push-id is the single-use capability; ~4s), ② post the full-screen incoming Connection (caller from payload), ③ connect WSS + REGISTER, ④ INVITE arrives → attach to the placeholder Connection (single call object). 30s no-INVITE safety end; caller-cancel; locked-screen answer; hangup → BYE.
  • Doze: no background socket held; FCM is the ring path. Server FCM sender is a stub today (§4.1) — finished in Phase C.

4.5 App shell — our existing One UI screens, re-pointed at AstraCall

viewmodels. Device Recents from a local Room call-log we write on each call; Company Recents stays the CDR API. Sign-in/line-picker/Pulse/Tickets unchanged.

5. Ours (move) vs derived (rewrite) — the Phase 0 inventory gate

Generated into the new repo as INVENTORY.md at scaffold time. Summary:

  • MOVE (clean-room, ours): org.linphone.astradial.{analytics,api,auth, tickets} (minus sip/AstradialSipProvisioner which is engine glue → rewrite), OneUiSettingsFragment, OneUiBottomBar, and every oneui_* / astradial_* / pulse_* layout, drawable, color, string, plus our nav-graph and theme edits.
  • REWRITE (upstream-derived, GPL): core/CoreContext + CorePush*/Notifications, all of ui/call/** (fragments, viewmodels, layouts — we rebuild against our own engine, reusing our One UI call layouts which we authored this week), TelecomConnectionService/glue, HistoryListViewModel/ContactsListViewModel (re-implement against Room/system-contacts, no liblinphone Core).
  • DROP: everything chat/meeting/conference/recordings/assistant-Linphone and the linphone-sdk-android dependency.

6. Phases & gates (each ends demonstrably working on Hari's moto g34)

✅ ENGINE PHASES A + B + C PASSED (2026-07-07, clean-room repo astradial-astracall-android, branch master). The Android app now registers, makes 2-way audio calls, AND rings a killed app — all on our own AstraSIP+WebRTC engine, zero liblinphone. Validated on the dedicated emulator engine_avd (emulator-5556) against staging. 33 JVM tests green. - A 93c8c7e — DigestAuth (MD5+SHA-256), WSS transport (OkHttp, sip subprotocol, 3-miss ping tolerance), RegistrationSession, AstraSipClient. Live gate: REGISTER→401→200 on staging, contact in pjsip show contacts, expiry/2 refresh, clean unregister. - B a87d9e3+7c0ebad+ce2c0eb — CallSession dialog state machine + Call-ID routing + WebRTC probe rig. Gate: outbound *43 + inbound both connect, getStats audio flowing BOTH directions, DTLS-SRTP over webrtc=yes, clean BYE both ways (0 orphan channels). Enforces single-active-call at the engine (found+fixed the orphan-call bug iOS also had). - C 9951ebc — self-managed ConnectionService/Connection, mic FGS, full-screen incoming activity, wake-v2 client (ack-FIRST → placeholder Connection → WSS+REGISTER → attach real INVITE by Call-ID; 30s no-INVITE timeout; caller-cancel→487). Audio-discipline carry-over applied: MODE_IN_COMMUNICATION + WebRTC audio start moved from onConnected to Connection.onAnswer() (the iOS no-audio-on-answer fix). Gate: killed app → wake → ring → 2-way audio → clean hangup. Only the FCM transport itself was simulated (adb broadcast with the real {caller,push-id} payload) — the ack POST got HTTP 200 from stagepbx live; swapping in a real FirebaseMessagingService is a 3-line delegation to the working WakeHandler.onWake(...).

Remaining engine work: D (hold/DTMF/transfer/routing) · E (resilience/soak) · F (cutover — port the One UI screens onto the AstraCall viewmodels, delete liblinphone from the ship path). The buzz/hangup/"not encrypted"-tag/linphone-call-UI complaints all live on the doomed liblinphone call path and resolve at cutover — do NOT patch them on the fork. Full real-device wake E2E additionally needs the prod FCM sender live (PR #511 is on staging) + google-services.json + a physical device.

Phase Deliverable Gate
0 — Scaffold + freeze astradial-android tagged linphone-stable-android (rollback). New clean-room repo astradial-astracall-android (astrasip JVM lib + skeleton). INVENTORY.md (ours vs derived). Inventory reviewed by Hari
A — Register AstraSIP-Kotlin: WSS connect, digest REGISTER, refresh at expiry/2, backoff. JUnit corpus tests. Minimal app registers from the phone. REGISTER 200 on staging; pjsip show contacts shows the transport=ws contact
B — First audio WebRTC + SDP↔INVITE glue; outbound + inbound ext↔ext; clean BYE both ways. Recorded 2-way audio, zero ICE/DTLS errors
C — Telecom + wake Self-managed ConnectionService, mic FGS, full-screen incoming (our One UI screen), FCM wake-v2 (push-ack→placeholder→attach); locked-screen answer. WAKE rows: fg/bg/killed/Doze/locked
D — Call features hold/resume (re-INVITE), DTMF, decline/busy, transfer via server feature codes, speaker/BT routing (AudioManager). OUT/INB/AUD UAT rows
E — Resilience WiFi↔LTE ICE restart, reconnect storms, register-expiry honesty, 24h soak + battery (WSS only while fg/in-call). NET + REG rows
F — Assembly + cutover Port our One UI screens onto AstraCall viewmodels; Room device call-log; delete the linphone fork from the ship path; WebRTC attribution; keep applicationId com.astradial.phone + keystore (FCM continuity). Full UAT; GPL-free binary; internal beta

Honest timeline: Phase A in days (server + corpus + spec exist), B ~1 week of device iteration, C–D week 2–3, E–F soak-dependent. ~3–5 weeks to internal beta, running in parallel with iOS Phase C (shared server + spec). Every liblinphone lesson from this wiki is a pre-paid shortcut.

7. Test strategy

  • AstraSIP JUnit tests (message corpus, auth, dialog state machines) — every build, no device. Shared corpus with iOS AstraSIP (same Asterisk).
  • Device matrix per gate = the UAT plan rows (WAKE/OUT/INB/AUD/NET/REG).
  • Current linphone-based app stays installed side-by-side (com.astradial.phone vs a temp .astracall id until cutover) — A/B the same scenarios.

8. Migration & cutover

  1. Rollback safety: the working liblinphone app is frozen at astradial-android tag linphone-stable-android (build + reinstall anytime).
  2. Per-user dual-stack: test extension gets webrtc=yes; UDP fleet untouched.
  3. When Phase E gates pass → new app adopts applicationId com.astradial.phone
  4. the existing keystore (keeps the FCM token + misssellerai Firebase Android app), old fork archived (repo kept for reference, never shipped).
  5. Play submission with no GPL in the binary; WebRTC BSD attribution in About.

9. Risks (ranked) & mitigations

  1. Telecom / OEM variance (Samsung, moto handle self-managed ConnectionService differently) — device-matrix testing in C; fallback is our own full-screen-intent notification path, which we fully control.
  2. Audio routing (AudioManager ↔ WebRTC modes, no-audio-on-answer class) — Android's CallKit-audio risk; budgeted device loops in B/D; known playbook.
  3. Doze vs ring — by design no background socket; FCM wake is the ring path (already validated server-side).
  4. Hostile carrier NAT breaks ICE — coturn contingency on the same VPS (half-day), only if NET rows fail.
  5. WebRTC binary dependency — pin + checksum; build-from-source later.
  6. Scope creep — §1 non-goals are the contract.

10. Day-one (Phase 0) checklist

  • Freeze tag linphone-stable-android on astradial-android private-main.
  • New repo Mobile App/astradial-astracall-android: astrasip JVM lib (Gradle, one runnable test), app skeleton, README, .gitignore, git init.
  • INVENTORY.md (ours vs derived) committed → Hari sign-off = Phase 0 gate.

11. Cross-platform learnings from iOS AstraCall (pre-paid shortcuts)

Direct from the iOS engine agent (same Asterisk, same spec — these are bugs already paid for once; do NOT re-derive). Each is tagged with the Android phase it lands in.

Message corpus (Phase A). iOS keeps its recorded-Asterisk messages + digest vectors inline in AstraSIP/Tests/AstraSIPTests/AstraSIPTests.swift (401/200/ INVITE bodies captured from stagesip.astradial.com). Same server → the shapes port directly. Ask the iOS agent to extract them to shared fixtures/*.txt; load as JVM test resources in astrasip/src/test/resources.

Digest auth — res_pjsip (Phase A). - Support MD5 (default when algorithm absent) and SHA-256; the token is "SHA-256" (hyphen), not SHA256h = algorithm.contains("SHA-256") ? sha256 : md5. - qop=auth is mandatory (Asterisk challenges qop="auth" or "auth,auth-int" → pick auth). response = H(HA1:nonce:nc:cnonce:qop:HA2), HA1=H(user:realm:pass), HA2=H(method:uri). nc = 8 hex (00000001), fresh cnonce. The non-qop form H(HA1:nonce:HA2) is only for challenges without qop. - Quote username/realm/nonce/uri/cnonce/opaque; do NOT quote nc/qop/algorithm. Echo opaque if present. uri must equal the request-URI exactly. → this is the astrasip digest unit-test target.

No-audio-on-answer fix (Phase B/D) — the important one. iOS: manual audio mode, enable media only in CallKit didActivate, never setActive yourself. Android translation: don't let WebRTC's AudioManager/audio focus take the session on answer — flip MODE_IN_COMMUNICATION + start WebRTC audio from the ConnectionService Connection's audio-state/route callback, not on the answer action. WebRTC owning the session before Telecom activates it = dead audio.

SDP↔INVITE glue (Phase B) — non-trickle. Wait for ICE gathering complete, put the full offer SDP in the INVITE body; the 200 OK carries the full answer → setRemoteDescription → send ACK. No candidates over SIP. Audio-only, OfferToReceiveAudio=true.

DTMF (Phase D). Endpoint dtmf_mode=rfc4733 (verified). libwebrtc-android RtpSender.dtmf() sends RFC 2833/4733 telephone-events → exact match, no mismatch. 100ms tone / 70ms gap works.

Wake-v2 edges beyond voip-push-wake.md (Phase C). - Ack FIRSTPOST the push-id ack as the very first line of onMessageReceived, before ConnectionService/WSS. Gateway races a 5s deadline; a late ack = failover. - Report placeholder synchronouslyaddNewIncomingCall in onMessageReceived (caller from payload), then connect+register, then attach the real INVITE to the same Connection. Never post a 2nd call. - 30s no-INVITE timeout — no INVITE within 30s (failed wake / caller hung up pre-register) → end the placeholder DISCONNECTED, else it hangs forever. - Caller-cancel — CANCEL during ring → server 487 → end the Connection. Route CANCEL/BYE to the session by Call-ID. - Stale-push guard — set FCM android.ttl="0s" (iOS apns-expiration:0) so an offline device doesn't get a queued push on reconnect → prevents a phantom "fake call". Bit iOS hard. - Registration honesty — on wake force a fresh REGISTER (don't trust stale reg); the gateway polls pjsip show contacts and needs the new contact.

Coordination: iOS token pipeline is live on staging (platform PR #500, voip_push_platform stored, android→FCM branch); editor has a Mobile Push admin column (token/test/clear) that will show Android tokens tagged Android. Keep the wake payload {caller, push-id} and the ack contract identical across both apps — coordinate before wiring fcmService. ✅ Android client side DONE 2026-07-07 — token POST + ack-first wake shipped on the liblinphone overlay (astradial-android private-main commit dc6278358, org.linphone.astradial.push.AstradialPush; contract identical to iOS, payload keys caller/push-id, ack {push_id}). The clean-room engine (this plan) reuses the same contract in Phase C; fcmService send is the remaining server piece.

12. iOS parity delta (2026-07-05) — features shipped + shared contracts

On 2026-07-05 iOS shipped to the App Store (AstraDial 1.0(1)) and built a batch of features on top of the working engine. The backend for all of them is platform-agnostic and now LIVE ON PROD (promoted via PR #504 to 147.93.168.216). Android replicates the client behaviour; the server contracts are done.

12.0 Shared backend contracts (done on prod — Android just calls them)

  • Per-endpoint codec / wss flavor — a user with sip_port='wss' gets webrtc=yes, transport=transport-wss, and allow=opus,alaw (Opus FEC for cellular loss + alaw for the Tata trunk). This is the flavor BOTH mobile apps register as. Generator: api/src/services/asterisk/userProvisioningService.js. Non-wss endpoints (ATAs/phones) are unchanged.
  • VoIP token contractPOST /api/v1/users/voip-push (or the users token route) carries {asterisk_endpoint, token, environment: 'dev'|'prod', platform: 'ios'|'android', ...}. Android sends platform:'android' + its FCM token; the wake gateway branches iOS→APNs / Android→FCM (fcmService). The token POST triggers a dialplan regen.
  • Click-to-callPOST /api/v1/calls/click-to-call body {from, to, to_type}. Rings from FIRST (a Local/<from>@<prefix>_internal originate), then bridges to to. to_type = 'external' for a PSTN number (>=7 digits), 'extension' for internal. Used by SIM Calls Only + ticket call-back. server.js ~5651.
  • Inbound routingPUT /api/v1/users/:id/routing body {ring_target:'phone', phone_number} points a SIP user's inbound at the SIM over PSTN; {ring_target:'ext'} reverts to the SIP line. Keyed by user id (look yours up in GET /api/v1/users by matching asterisk_endpoint).
  • CDR fields for RecentsGET /api/v1/calls returns raw rows INCLUDING channel and dstchannel. Critical for outbound-PSTN (see 12.5).

12.1 SIM Calls Only (cellular answer — G.711 has no FEC → data VoIP is choppy)

Settings toggle + a 10-digit SIM number field. ON: call setSIMRouting (routing ring_target=phone) so inbound rings the SIM; outbound (and ticket call-back) route through click-to-call (from=SIM, to=dest) — the PBX rings the SIM first then bridges, with a "Calling your phone…" toast, no VoIP screen. OFF: routing back to ext, outbound back to VoIP. - iOS gotcha → Android: iOS cannot read the SIM number (privacy) — the field pre-fills from the user's Astradial record (phone_number/failover_phone_number) to confirm. Android can read it (TelephonyManager.getLine1Number() with READ_PHONE_NUMBERS permission, often null on eSIM/carrier) — try that, fall back to the same record pre-fill. - Toggle UX bug paid for once: don't apply routing inside the toggle's onChange — the number field only appears after the toggle flips ON, so it sees an empty number and the toggle snaps back. Reveal the field on ON; apply routing on an explicit Apply button (or after a valid number is entered). OFF reverts routing immediately.

12.2 Codec device toggle (Call Audio: High Quality / Save Data)

Settings dropdown. Opus is negotiated by default (endpoint allow=opus,alaw). The toggle caps the Opus send bitrate: High=40kbps, Save Data=16kbps (more FEC headroom on weak networks). No-op on alaw. - iOS also had an experimental alaw-vs-Opus SDP-munge (reorder m=audio to prefer PCMA); final UX is the bitrate toggle. Android: set RtpParameters.encodings[0].maxBitrateBps on the audio sender; same values.

12.3 RBAC — analytics + recordings are owner/admin/manager only

MDSession.role (from the platform JWT: owner/admin/manager/agent). canManage = role in {owner,admin,manager}. Gates: Analytics tab (hidden for agents; icon sparkles.2) and the call-recording play button. Default landing tab = Tickets. Android: same role gate on the Pulse/Analytics destination + the recording control.

12.4 Recording playback + floating player

Recents rows with a recording show a play button (RBAC-gated). Playback needs an authenticated media fetch: AVURLAsset with an Authorization: Bearer <platformJWT> header on recording_url (prefix with API base if relative). A floating bottom bar shows title + a progress bar + elapsed/total seconds + pause/close. - iOS gotcha → Android: WebRTC leaves the audio session in manual/inactive mode, so AVPlayer was silent until we set an explicit .playback category + setActive(true). Android translation: don't play through the VoIP/MODE_IN_COMMUNICATION route — use a normal MediaPlayer/ExoPlayer with AudioAttributes USAGE_MEDIA, and pass the Bearer header via setDataSource(url, headers).

12.5 Recents rebuilt — single-day, own-calls, outbound-PSTN, strip-91

  • Single day only: filter dropdown Today / Yesterday / Custom-date (a date picker); the load fetches exactly that 24h window (from=midnight, to=+24h). Icon line.3.horizontal.decrease.circle.fill. Load on tab open (not pull-to-refresh only).
  • Own calls only: filter org CDRs to THIS user. src/dst match internal calls, but for outbound PSTN the CDR src is the presented caller-ID DID, not your extension — so ALSO match your endpoint against channel/dstchannel (they carry PJSIP/<endpoint>-… on your leg). Without this, outbound PSTN calls are missing. (This was the key bug.)
  • Which party to show: pick by which leg is yours — channel contains your endpoint → you called → show dst; dstchannel contains it → you were called → show src. Do NOT rely on the API direction field (unreliable for softphone→PSTN; it showed our own DID).
  • Strip country code: display the last 10 digits for any number with ≥11 digits (+9198765432109876543210); leave short numbers/extensions/names alone.
  • Info (ⓘ) opens a detail sheet: Call ID, Linked ID, From, Rang, Answered by.

12.6 Settings compaction + loading shimmer

Connect Type dropdown (Quick / Manual / Scan QR) replacing separate sections; Server URL merged into the SIP Account card (small grey right-aligned); a Name field shown top-left on the keypad (CFBundleDisplayName-independent, user-set display name); Call Audio as a dropdown. Tickets + Recents show a redacted shimmer placeholder while loading (a loaded flag on each view model). Android: mirror with your One UI equivalents.

12.7 Store submission learnings (for the Play Store parallel)

  • Privacy: a login app must declare it collects the sign-in email (App Functionality, linked, not tracking) — "no data collected" with a login screen gets rejected. Play Store's Data Safety form is the analogue.
  • Age rating: business phone tool → all "No" → everyone/4+.
  • Runbook (iOS, adapt for Play): App Store/A tour to app store.md.