AstraCall Engine — Build Plan (replace liblinphone, calls only)¶
Decision (Hari, 2026-07-03): no Belledonne commercial license. We replace the GPL liblinphone stack with our own phone-side engine talking to our existing Asterisk. Scope = one feature: highly reliable, stable calls. No chat, no meetings, no recordings tabs, no extra UI surfaces. This document is the build spec; the UAT plan's call rows are the acceptance contract.
Related: voip-push-wake.md (carries over verbatim), ios-uat-test-plan.md (parity contract), decisions.md (licence question → resolved by this plan).
1. Goal & non-goals¶
Goal: an iOS app whose entire job is: register to our Asterisk, ring reliably in every app/phone state, connect fast with 2-way audio that survives network changes, hang up cleanly — with zero GPL code, App-Store-submittable.
Non-goals (v1): chat/conversations, meetings, recordings browser, video, contacts sync, MD analytics/tickets (stay in the existing internal app), multi-account, custom in-call UI beyond essentials (CallKit's native UI is the primary call surface).
2. Licensing outcome¶
| Component | License | Status |
|---|---|---|
| AstraCall engine (Swift SIP + glue) | Ours, proprietary | written by us |
| Google WebRTC (media) | BSD-3 | App-Store safe, attribution in About |
| App UI shell | Ours | written fresh — no upstream-derived files copied |
| liblinphone / linphone-iphone | GPLv3 | gone at cutover — L-00 gate dissolves |
Rule during the build: knowledge and our design docs carry over; no Belledonne source is copied into the new target. Files we authored from scratch in the old app (AstradialMD-era screens) are ours to reuse; anything derived from upstream (call screens, TelecomManager/CoreContext glue) gets rewritten against the same behavioral spec (this wiki).
3. Architecture¶
┌──────────────────────────── iPhone ────────────────────────────┐
│ App shell (SwiftUI, minimal): sign-in → pick extension → │
│ keypad · settings-lite (expiry, logout) · CallKit native UI │
│────────────────────────────────────────────────────────────────│
│ AstraCallKit (ours): CXProvider/PushKit glue — wake v2 client │
│ (push-ack POST, placeholder call, no phantom sessions ever) │
│────────────────────────────────────────────────────────────────│
│ AstraSIP (ours, ~3-5k lines): SIP-over-WSS — REGISTER/INVITE/ │
│ ACK/BYE/CANCEL/re-INVITE(hold)/OPTIONS · digest auth · │
│ dialog & transaction state machines (reliable transport → │
│ NO UDP retransmission machinery) · reconnect w/ backoff │
│────────────────────────────────────────────────────────────────│
│ Google WebRTC (BSD): PeerConnection audio · Opus/G711 · echo │
│ cancel · jitter · DTLS-SRTP · ICE · DTMF (RFC4733 sender) │
└───────────────┬────────────────────────────────────────────────┘
│ WSS :8089 (SIP) + SRTP/ICE (media)
┌───────────────▼────────────── Asterisk (existing) ─────────────┐
│ transport-wss + endpoint webrtc=yes (generator flag) │
│ EVERYTHING ELSE UNCHANGED: dialplan, wake endpoint + push-ack, │
│ queues/IVR/failover, CDR, MixMonitor recording, expiry/qualify │
└────────────────────────────────────────────────────────────────┘
Key simplifications vs liblinphone we get for free:
- WSS = reliable transport → no SIP retransmit timers, no NAT rport dance; the TCP/TLS connection is the NAT pinhole and the server sees disconnects instantly (sharper than qualify).
- Our push, our call objects → the entire liblinphone pushkit-phantom bug class (call-id dedup, 15s zombie placeholder,
processPushNotification) is structurally impossible. - One server (ours) → we implement the SIP subset both ends actually speak, not the RFC universe.
4. Component specs¶
4.1 Server (config-only, via repo + deploy)¶
http.conf: enable TLS server :8089, Let's Encrypt cert forstagesip.astradial.com/devsip.astradial.com(certbot renew hook →module reload http).- pjsip: add
[transport-wss] type=transport, protocol=wss. userProvisioningService: new per-user flag (e.g.sip_port='wss'or a dedicated column) emitswebrtc=yesendpoints (Asterisk auto-setsdtls_auto_generate_cert-style params: usewebrtc=yes+dtls_cert_filefrom the LE cert). Dual-stack: UDP users untouched; migration is per-user.- Wake endpoint/dialplan/ack: zero changes (transport-agnostic).
- Contingency (only if NET tests fail on hostile carrier NAT): coturn on the same VPS,
ice_host_candidates/STUN config. Not built until proven needed.
4.2 AstraSIP (Swift package, ours)¶
- Message layer: parser/serializer for the subset; strict on what we send, tolerant on what Asterisk sends. Full unit-test corpus from live Asterisk captures.
- Transactions/dialogs: INVITE client+server transactions on reliable transport; CSeq/route/tag handling; digest auth (MD5 + SHA-256) on 401/407; REGISTER refresh at expiry/2 honoring the Settings picker (30s–5m, server floor/cap 30/300).
- Transport:
URLSessionWebSocketTask(sipsubprotocol per RFC 7118), ping keepalive, exponential-backoff reconnect,refreshRegisters()equivalent on wake/foreground/network-path change (NWPathMonitor). - API surface mirrors the behaviors the app already depends on (the wake v2 client contract):
register/unregister,call(uri),accept/terminate/ hold/resume/sendDTMF, delegate events (incoming, state, media-connected).
4.3 Media (Google WebRTC iOS)¶
- Audio-only
RTCPeerConnectionper call; SDP offer/answer glued into INVITE/200/ACK (and re-INVITE for hold:a=sendonly/MOH from Asterisk). - CallKit audio discipline (the known-hard part, budgeted for on-device iteration):
RTCAudioSessionmanual mode; activate media only inprovider(didActivate:); the answer-before-INVITE deferred-accept behavior reimplemented per wake v2. - DTMF:
RTCRtpSender.dtmfSender(RFC 4733) — matches Asterisk endpointdtmf_mode=rfc4733. - Dependency: pin Google WebRTC iOS release (SPM binary, checksum-pinned; build-from-source is the later hardening option).
4.4 AstraCallKit + wake v2 client (port of validated design)¶
- PushKit: on push → ① POST push-ack (
push-id) before anything else, ② report CallKit call (caller number from payload), ③ connect WSS + REGISTER, ④ INVITE arrives → attach to the placeholder call (single call object — no re-keying gymnastics needed). - 30s no-INVITE safety end; caller-cancel handling; locked-screen answer; hangup → BYE (+ engine-level guarantee: pending actions resolve against the one live call).
4.5 App shell (fresh target AstraCall, SwiftUI, ours)¶
Screens, total: Sign-in (Firebase → org JWT) → Quick Connect (pick extension; fixes DEF-006 in the rewrite) → Keypad → Settings-lite (expiry picker, logout, About/licenses). In-call UX = CallKit native UI + one minimal in-app call card (mute/speaker/keypad/hold/hang up). Recents can come from the platform CDR API later — not a v1 gate.
5. Phases & gates (each phase ends demonstrably working on the iPhone)¶
| Phase | Deliverable | Gate |
|---|---|---|
| A — Door + skeleton ✅ 07-03 | staging WSS live; AstraSIP registers from the iPhone; contact visible via transport=ws | REGISTER 200 + expiry honored — PASSED |
| B — First audio ✅ 07-03 | INVITE in/out with WebRTC audio, ext↔ext on staging | 2-way audio both directions — PASSED (recorded, clean hangups, zero ICE/DTLS errors) |
| C — CallKit + wake | PushKit/CallKit port; killed/background/locked answer; wake v2 E2E (server untouched) | WAKE-01..07, 13, 14 pass |
| D — Call features | hold/resume, DTMF, transfer via server features, busy/decline paths, failover interplay | OUT/INB/AUD P1 rows pass |
| E — Resilience | WiFi↔LTE ICE restart, reconnect storms, expiry honesty, 24h soak | NET rows + REG-05/06 pass |
| F — Ship | shell polish, privacy manifest, export-compliance key, TestFlight, prod E2E, App Store | Launch gates (L-01..13, no L-00) |
Honest timeline: A today; B within days (audio glue is the first device-iteration loop); C–D the second week; E–F depend on soak results and review turnaround. Aggressive-realistic: 2–4 weeks to TestFlight; "today" means Phase A live today, not App Store today. Every liblinphone lesson from 2026-06-30→07-03 (this wiki) is a pre-paid shortcut — that's why weeks, not months.
6. Test strategy¶
- AstraSIP unit tests: message corpus (recorded from our Asterisk), auth, dialog state machines — runs on every build, no device needed.
- Device matrix per phase gate = the existing UAT plan rows (WAKE/OUT/INB/ AUD/NET/REG) — the old app's validated behavior is the spec.
- Old app stays installed side-by-side during the build (different bundle id for the new target until cutover) — A/B the same scenarios.
7. Migration & cutover¶
Rollback safety: the working liblinphone app is frozen at tag linphone-stable-2026-07-03 / branch linphone-stable (astradial-ios, commit 259704a14) — build + reinstall it anytime if the new engine slips.
- Per-user dual-stack: test extensions get
webrtc=yesendpoints; UDP fleet untouched. - When Phase E gates pass → new target adopts bundle id
com.astradial.softphone(keeps the push token/entitlements), old linphone-based app archived (repo stays for reference; never shipped). - App Store submission with no GPL in the binary; L-00 gate deleted; remaining gates: privacy manifest, export key, review kit, demo account.
- Existing internal MD app (analytics/tickets) remains a separate internal tool — not part of this product.
8. Risks (ranked) & mitigations¶
- CallKit ↔ WebRTC audio-session timing (no-audio-on-answer class) — budgeted device-iteration loops; known playbook (manual RTCAudioSession, didActivate discipline); worst case mirrors the fixes we already shipped once this week.
- Hostile carrier NAT breaks ICE — coturn contingency (half-day), only if NET tests fail.
- WebRTC binary dependency — pin + checksum; later build-from-source.
- Scope creep — this doc's non-goals section is the contract; anything not "call" goes to the backlog.
- Asterisk WSS cert/renewal ops — certbot deploy-hook + monitoring (existing Upptime can probe :8089).
9. Day-one (Phase A) checklist¶
- LE cert on stagesip + http.conf TLS + transport-wss (staging, synced back to repo per staging-direct-edit workflow)
- Generator:
webrtc=yesendpoint flag for a test user (1005-wss) - Repo: new
AstraCallXcode target +AstraSIPSwift package skeleton - WSS connect + REGISTER + 200 (digest) from the iPhone on staging
-
pjsip show contactsshows the WSS contact; wake endpoint sees it reachable (zero server logic changes)