Skip to content

AstraCall — Execution Blueprint (Fable 5 → Opus handover)

Purpose: everything Opus needs to build AstraCall to App-Store submission without re-deriving context. Plan authored by Fable 5, 2026-07-03. Companion docs: astracall-engine-plan.md (architecture spec), voip-push-wake.md (wake design — reused verbatim), ios-uat-test-plan.md (acceptance contract), troubleshooting Errors 78–83 (every bug already solved once).


0.001 CURRENT STATE — read this first (2026-07-05)

Phases A–F all done; build 1.0(1) uploaded to App Store Connect. On 2026-07-05 the app went through Phase E (resilience) and Phase F (store prep) and a distribution build was uploaded to TestFlight via the ASC API key. Store name AstraDial (CFBundleDisplayName + ASC listing); bundle stays com.astradial.astracall. Full release runbook (real IDs, commands, gotchas): /Users/hari/AstradialDevelopment/App Store/A tour to app store.md.

Shipped this day on top of the working engine: - SIM Calls Only — Settings toggle: click-to-call (POST /api/v1/calls/click-to-call) rings the SIM first then the destination; toggle sets the SIP user's routing ring_target=phone; ticket call-back uses it too. Cellular answer (G.711 has no FEC). - Codec — per-endpoint Opus w/ in-band FEC for the wss flavor (PR #502); device toggle High Quality/Save Data (Opus bitrate). codecs.conf [opus] fec=yes packet_loss=10 appended on staging but pending an Asterisk restart (codec_opus can't hot-reload; module reload was declined) → downlink FEC activates next deploy. - RBAC — Analytics tab + call-recording playback gated to owner/admin/manager (MDSession.canManage). Analytics tab icon sparkles.2, opens on Tickets by default. - Recording playback — auth'd (Bearer) AVURLAsset + floating player bar with elapsed/total seconds; needs a .playback session (WebRTC leaves it manual/inactive). - Recents rebuilt — single-day filter (Today/Yesterday/Custom date), loads the selected 24h window; outbound-PSTN now shown by matching own endpoint against CDR channel/dstchannel (src is the presented DID on outbound); shows the OTHER party and strips country code to last 10 digits; loading shimmer; info-icon → detail sheet. - Settings compaction — Connect Type dropdown (Quick/Manual/Scan QR), Server URL merged in, Name field (shown top-left on keypad), Accounts card moved to bottom. - Phase F2 compliancePrivacyInfo.xcprivacy, ITSAppUsesNonExemptEncryption=false, version 1.0(1), per-config aps-environment (Debug=development, Release=production), About → Version + Acknowledgements (WebRTC BSD / Firebase Apache-2.0 / AstraSIP own).

SUBMITTED to App Store review 2026-07-05 (build + demo account + review notes → Add for Review). Awaiting Apple. Still open (not blocking review): prod E2E wake test (DEDICATED new ext → sip_port='wss', never an ATA ext; on prod 147.93.168.216, confirm APNs key PF5LTXJ66D); codecs.conf Opus-FEC Asterisk restart; CI/CD self-hosted runner on the new prod 147.93.168.216.

0.00 CURRENT STATE — 2026-07-04 PM (superseded by 0.001 above)

Where we are: The FULL Astradial app UI is ported onto our own engine and builds + installs + registers on the device. Repo: Mobile App/astradial-astracall (github astradial/astradial-astracall, private, branch main). App target AstraCall, bundle com.astradial.astracall, built with xcodegen generate + xcodebuild (needs DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer). Device: hari's iPhone udid 94D70216-2080-56CA-B8C5-ECAC8A7D654E (wireless; needs Developer Mode ON + unlocked). Test extension: ext 1003 = e1003ra9v72, sip_port='wss' on staging; register with domain stagesip.astradial.com (NOT Quick Connect, which defaults to prod devsip).

✅ Working (2026-07-04 PM — device-verified by Hari): the FULL Astradial app places + receives calls on our own engine — outbound (dial → native call screen + ringback + audio), inbound (native CallKit ring → answer → audio), CallKit UI, in-call screen, clean hangup (BYE sent, both Asterisk legs torn down). Plus: Phase A register, Phase B audio, full UI, branding (indigo splash + AppIcon + logo), Firebase auth, QR scan.

Config gaps fixed today (all were greenfield-app plist/entitlement holes, NOT engine bugs — pattern to remember: the new app lacked keys the old fork had): - SIP register failed silently → domain field carried host:port, facade built wss://host:port:8089/ws (double port). Fix: StoredSIPAccount.host sanitizes. - CallKit .unentitled (Code=1) + provider XPC teardown → UIBackgroundModes was MISSING from the built Info.plist (INFOPLIST_KEY_UIBackgroundModes string produces nothing). Fix: xcodegen info: generator with UIBackgroundModes: [voip, audio, remote-notification] + CXProviderConfiguration(localizedName:). - QR scan crashed → missing NSCameraUsageDescription. Fix: added (mic + camera + voip now all present). - No call screen on outbound → CallKit shows no UI for outbound; app must present NativeCallView. Fix: drive TelecomManager flags (callDisplayed/callInProgress/ outgoingCallStarted/callConnected) from call state in AstraCallManager. - No ringback → 180 has no early media. Fix: RingbackPlayer (synthesized 400Hz India-cadence via AVAudioEngine), played while ringing, stopped on connect/end.

⚠️ Not-our-bug caveat: inbound hangup "doesn't hang up 1001" = Telephone 1.6 (the Mac test client) ignoring BYE — troubleshooting Error 83, 3rd occurrence, packet-proven. Our stack + Asterisk are correct (hangup SOURCE=e1003, both legs torn down). TEST WITH A MODERN CLIENT (Linphone desktop / Zoiper / 2nd iPhone), not Telephone 1.6.

❌ OPEN BUGS: none blocking calls (the earlier can't-call bug is RESOLVED — see fixes above).

Deliberately deferred (NOT bugs): Recents list empty (CDR→HistoryModel mapping is Phase D — CallHistory.swift.load() is a stub), in-call DTMF + hold are UI stubs (AstraCallManager.sendDTMF no-op; togglePause UI-only), PushKit/killed-app wake not wired (Phase C2), Quick Connect points at prod.

Key files (astradial-astracall): - AstraSIP/ package — SIP-over-WSS engine (SIPMessage, DigestAuth, Transport, RegistrationSession, CallSession, AstraSIPClient). 11 unit tests green. - AstraCall/AstraCallManager.swift — CallKit bridge (CXProvider/Controller). - AstraCall/MediaSession.swift — WebRTC (RTCPeerConnection audio, DTLS-SRTP). - AstraCall/Engine/EngineFacade.swift — CoreContext/TelecomManager/ AccountLoginViewModel/EngineCore/AccountModel (fresh, plain classes, no GPL). - AstraCall/Engine/CallViewModel.swift, CallHistory.swift, ToastView.swift, Log.swift — facade view models + shims. - AstraCall/Screens/ — the 6 ported product files (verbatim UI, rewired imports). - AstraCall/AstraCallApp.swift — @main + RootView (splash → onboarding/tabs) + Firebase config. GoogleService-Info.plist copied (gitignored).

Phase D — ✅ DONE + device-verified: DTMF (RTCDtmfSender/RFC2833, endpoint dtmf_mode=rfc4733), Recents→CDR (fetchCalls→HistoryModel, deduped), MOH hold (re-INVITE a=sendonly → Asterisk plays MOH to the far leg), dual-tone ringback (440+480Hz, real-format PCM buffer looped via AVAudioPlayerNode — survives the VoIP session's format churn + speaker-toggle route changes; fresh engine per call), earpiece/speaker routing (no .defaultToSpeaker wedge), portrait-lock. Immediate next step: Phase E (resilience/ICE-restart/soak) → F (privacy manifest + export compliance + adopt com.astradial.softphone + TestFlight + App Store). No GPL blocker. Phase C (CallKit + killed-app push wake) COMPLETE.

Platform/push infra (shipped, staging — PR #500): the push pipeline is now platform-aware (users.voip_push_platform 'ios'|'android'; wake gateway branches APNs vs FCM; fcmService stub for the Android app to finish). iOS app sends platform:"ios". Editor Mobile Push column + admin dialog (token/test/clear/ paste) on the Users page — DOC PENDING Hari's staging test of the dialog. Android server contract: astracall-android-plan.md §4.1 + voip-push-wake.md "Cross-platform".

0. The one-paragraph brief

Replace the GPL liblinphone engine with our own. iOS VoIP app for hospitals, calls only — no chat/meetings/recordings/analytics. Phone-side = our Swift SIP-over-WSS core (AstraSIP, built + unit-tested) + Google WebRTC (BSD) for media. Server = our existing Asterisk, unchanged except a per-user webrtc=yes endpoint flavor. Removing liblinphone deletes the GPL/App-Store blocker entirely. The old app is frozen and recoverable; nothing here touches it.

0.05 CORRECTED SCOPE (2026-07-04) — engine goes UNDER the existing UI

Hari clarified: the goal was never a from-scratch UI — it's our engine under the Astradial product UI. Scoping scan of astradial-ios:

  • Your product screens are clean + yours (~4,400 lines, NO GPL header, touch the SDK via one import linphonesw each): UI/Main/AstradialMD.swift (1667), NativePhoneUI.swift (1170), TicketsUI.swift (661), NativeCallUI.swift (344), OnboardingUI.swift (200), AstradialAccounts.swift (379).
  • The rest is a GPL fork: 186 files carry the Belledonne/GPL header (TelecomManager, CoreContext, ProviderDelegate + all Assistant/Welcome/ Conversations/Meetings screens). 100 files import the SDK.

Load-bearing consequence: swapping the engine in place inside astradial-ios leaves 186 GPL files → the app stays GPLv3 → cannot ship to App Store. The GPL is in the app source, not just the SDK.

Decision path (recommended, pending Hari's final confirm): port the 6 clean product files into the GPL-free astradial-astracall repo, rewired to AstraSIP + AstraCallManager; drop the 186 GPL files (call-glue is replaced by our manager anyway; chat/meetings are out of scope). Same rewiring effort as in-place, but the result is App-Store-shippable. Adopts com.astradial.softphone at cutover. This still fully honors "reuse our UI" — the app's face is unchanged; only the host repo + the engine underneath differ.

0.06 PORT decisions (2026-07-04) + port plan

Scope: FULL app port (Hari confirmed) — dialer + call + MD analytics + tickets + org directory + Firebase auth, GPL-free, feature parity with today. UI: reuse existing screens verbatim (NativePhoneUI/NativeCallUI etc.), rewired to the engine — the app's look is unchanged.

Repo: no change to astradial-ios (stays the frozen GPL fork / rollback). Ship from astradial-astracall. Copy ONLY the 6 clean product files (Hari's own copyright, no Belledonne header); never the 186 GPL files. Rewire off liblinphone → AstraSIP + AstraCallManager. Legally clean because we move Astradial's own code and remove the GPL link.

Dependency map (verified): support layer (PlatformAuth, MDSession, AstradialAPI, AstradialHTTP, AstradialAPIConfig) + models (CDRCall, OrgUser, Ticket, SIPProvisioning) are all defined INSIDE the 6 clean files — they come along. External deps: FirebaseAuth (added), a Log shim (added), stdlib SwiftUI colors. Only ~13 liblinphone touchpoints to rewire: core.defaultAccount (6), clearAccounts/clearAllAuthInfo (logout), interpretUrl (dial parse), currentCall, callLogs (recents), doCallOrJoinConf(address:).

Engine facade (built, AstraCall/Engine/EngineFacade.swift): fresh Astradial-owned CoreContext / TelecomManager / AccountLoginViewModel / EngineCore matching the exact surface the UI calls, backed by AstraSIP — so ported screens compile with minimal edits.

Port order: Log+facade ✅ → copy 6 files + swap import ✅ → compile-driven rewire of the ~13 touchpoints → root ContentView (tabs) → device gate (real Astradial UI places/receives a call on our engine) → Phase C2 push → D/E/F.

0.1 Agile mapping (Hari's working method)

Agile stage AstraCall artifact
Plan the requirement astracall-engine-plan.md §1 goal/non-goals (calls only)
Gather scope Blueprint §3 task list, phase gates = scope freeze per phase
Code Phase tasks; every change committed to astradial-astracall / platform PRs
Test Per-phase device gates + AstraSIP unit suite + UAT plan call rows
Launch Phase F (TestFlight → prod E2E → App Store)

Working loop per phase: plan (this doc) → code → device gate with Hari → mark evidence here → update status.md → next phase.

1. Ground truth (verified, do not re-check)

Fact Value
Staging Asterisk 94.136.188.221, stagesip.astradial.com
WSS transport already live: [transport-wss] bind :8089, HTTPS + LE cert, res_http_websocket loaded
Prod Asterisk 147.93.168.216 (devsip.astradial.com) — untouched until cutover
Wake pipeline (v2) live on staging: push-ack + qualify-verify + apns-expiration:0; PRs #494–#497 merged
Test org 7f3d2fd5-347e-4cc2-a2d0-a9a5e0f78f79 (ext 1001 Mac softphone, 1005 old app)
Device hari's iPhone 12 mini, udid 94D70216-2080-56CA-B8C5-ECAC8A7D654E, device-only, never simulator
Build toolchain DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer (CLT lacks XCTest + iOS platform)
Old app rollback astradial-ios tag linphone-stable-2026-07-03 / branch linphone-stable (@ 259704a14)

2. Repos & artifacts already created

  • astradial-platform PR #498 (feat/wss-endpoint-flavor) — MERGED + deployed 2026-07-03. sip_port='wss'transport=transport-wss + webrtc=yes. Test user ext 1003 (e1003ra9v72) flipped and live.
  • Mobile App/astradial-astracall/ — github.com/astradial/astradial-astracall (private, main @ 57e76bf). Contains:
  • AstraSIP/ Swift package — built, 11 tests green: SIPMessage (parse/serialize), DigestAuth (MD5+SHA-256, RFC vectors), Transport (WSS + mock seam), RegistrationSession (full REGISTER FSM), AstraSIPClient (facade; answers OPTIONS, 486s INVITE until Phase B).
  • AstraCall/AstraCallApp.swift — Phase A probe UI (one screen).
  • project.yml (xcodegen; xcodegen generateAstraCall.xcodeproj), bundle com.astradial.astracall, team L7RZB3SY47, iOS 17, mic string.

3. Task list (execute in order; each ends demonstrably working on device)

PHASE A — WSS registration ✅ PASSED 2026-07-03 17:32 IST

  • A1 PR #498 merged + deployed (staging regen applied).
  • A2 Test extension: ext 1003 = endpoint e1003ra9v72 flipped to sip_port='wss' via API (internal-key + org_id) → auto-regen emitted transport=transport-wss + webrtc=yes (verified in pjsip_astraprivate.conf). SIP password read from stageeditor (never in transcripts).
  • A3 GATE PASSED: REGISTER→401→digest→200 from the iPhone over wss://stagesip.astradial.com:8089/ws; Asterisk log: contact added with transport=ws, x-ast-orig-host=….invalid rewrite, expiry 120s honored; qualify OPTIONS answered by our client (Avail, RTT 45ms); refresh at expiry/2 verified across multiple cycles.

PHASE B — First WebRTC audio ✅ PASSED 2026-07-03 ~17:46–18:00 IST

  • B1 Google WebRTC added (stasel/WebRTC SPM, from 126.0.0, resolved + committed in the Xcode project).
  • B2 MediaSession.swift (app target): audio-only RTCPeerConnection, unified-plan + maxBundle + rtcp-mux required + gatherOnce; non-trickle (full SDP handed up on ICE-gathering-complete); RTCAudioSession manual mode with explicit startAudio() (CallKit takes over in Phase C).
  • B3 CallSession (AstraSIP): INVITE w/ SDP offer → 100/180/200→ACK (2xx-ACK new branch, non-2xx-ACK same branch), digest retry on INVITE 401/407, CANCEL pre-answer, BYE established, inbound INVITE→180→accept (200+answer SDP)/decline, dialog bookkeeping (tags/CSeq/remote target). Client routes by Call-ID; re-INVITE (hold) deferred to Phase D (488 for now).
  • B4 GATE PASSED both directions: app(1003)→Mac(1001) ANSWERED, 13s billsec, 2-way audio, recorded by MixMonitor (20260703-174633-1003-1001.wav), clean local hangup; Mac→app auto-answer bench, 2-way audio, clean remote hangup. Asterisk: "Strict RTP learning after ICE completion", zero DTLS/ICE errors, first attempt. No coturn needed on this network.

PHASE C — CallKit + wake v2 client ✅ DONE + device-verified (2026-07-04): foreground calls AND killed-app push wake both ring on our engine

  • C1 AstraCallManager (CXProvider/CXCallController) — outgoing + incoming call reporting, answer/end/mute/hold/DTMF actions.
  • C2 PushKit: register VoIP token → POST /users/voip-push-token (reuse platform endpoint). On push: ① POST /users/voip-push-ack (push-id) FIRST, ② report CallKit call w/ caller number, ③ connect WSS + REGISTER, ④ attach the arriving INVITE to the reported call. Single call object — the liblinphone phantom/re-key class is gone.
  • C3 Audio-session discipline done (RTCAudioSession CallKit handoff in provider didActivate/didDeactivate). Ringback + in-call UI flags wired.
  • C4 GATE: WAKE-01..07, 13, 14 from the UAT plan (killed, background, locked, offline→failover, reconnect-no-fake-call). Server is untouched — reuse the exact staging wake pipeline.

PHASE D — Call features & correctness

  • D1 Hold/resume (re-INVITE sendonly + MOH), DTMF (RFC 4733 via RTCDtmfSender), attended/blind transfer via server (REFER or dialplan feature codes — match what the old app used).
  • D2 Decline/busy paths, first-call-after-launch (must NOT regress the old DEF — reliable transport should make it a non-issue), duration timer.
  • D3 GATE: OUT/INB/AUD P1 rows of the UAT plan.

PHASE E — Resilience & soak ✅ DONE (2026-07-05)

  • E1 WiFi↔LTE mid-call: ICE restart (WebRTC restartIce()) + re-INVITE; NWPathMonitor drives refreshRegistration(). Made gentle (debounced refreshRegistration only) — aggressive disconnect churned cellular.
  • E2 Transport ping tolerance (reconnect only after 3 consecutive misses), expiry honesty. NOTE: the in-app SIP-registration timing picker was removed — wake is handled via APNs, so it wasn't needed.
  • E3 Cellular stabilised (ICE-gathering 1s cap fixed the 40s connect delay; STUN reflexive candidates; registration no longer churns on cellular).

PHASE F — App shell + store submission ✅ F1+F2 DONE, F3 in progress (2026-07-05)

  • F1 PORT the 6 clean Astradial product files (AstradialMD, NativePhoneUI, TicketsUI, NativeCallUI, OnboardingUI, AstradialAccounts) into astradial-astracall, rewiring each import linphonesw + Core/Call references to AstraSIP + AstraCallManager. DEF-006 (Quick Connect empty) fixed in the port. Add About+licenses (WebRTC BSD attribution). Drop the 186 GPL files (not carried over).
  • F2 Compliance (2026-07-05): PrivacyInfo.xcprivacy (required-reason APIs + Firebase/Crashlytics data categories), ITSAppUsesNonExemptEncryption=false, real GoogleService-Info.plist in archive, aps-environment=production on distribution signing.
  • [~] F3 IN PROGRESS (2026-07-05): build AstraDial 1.0(1) exported + uploaded to App Store Connect via the ASC API key (Key BVWW6PV9N3) — "Processing" under TestFlight. ASC metadata all filled (screenshots, 4+ rating, privacy = Email/App-Functionality, Free, Business category, privacy policy https://astradial.com/privacypolicy). REMAINING: attach build + demo account + review notes → Submit; prod E2E wake (flip a prod ext to WSS, WAKE-10); codecs.conf Opus-FEC restart; staging UAT sweep. Full runbook: App Store/A tour to app store.md.

4. Design rules (firm)

  1. No Belledonne source copied into astradial-astracall. Behaviors are re-implemented from this wiki, not from liblinphone files.
  2. Platform changes → PR to staging only, never self-merge, never push to a merged branch (repo CLAUDE.md Rules 1/2/8).
  3. No hand-edits to /etc/asterisk — the WSS flavor flows through the generator + deploy regen.
  4. Device-only testing (no simulator) — everything that matters is push/CallKit/audio.
  5. Don't dump SIP passwords to the transcript — read them in the editor/app.
  6. Wake pipeline is done — Phase C is a client port, not a redesign.

5. Known-hard spots (pre-warned, with the playbook)

  • CallKit ↔ WebRTC audio timing (no-audio-on-answer): manual RTCAudioSession, activate in didActivate, mirror the deferred-accept we already shipped. Budget device iterations.
  • Asterisk WebRTC SDP quirks: needs webrtc=yes (done via flavor → DTLS-SRTP, ICE, AVPF, rtcp-mux, bundle). If 488/no-media, compare the offer against a working browser-webphone offer.
  • First-call-after-launch old bug: expected to vanish (persistent WSS vs cold UDP socket) — verify, don't assume.

6. Definition of done

All P1 UAT rows green on staging AND a prod extension, privacy/export compliance in place, TestFlight build reviewed, zero GPL in the binary, old app still recoverable. Then submit.