iOS VoIP Push Wake — Design & Runbook¶
Validated E2E on staging 2026-07-03 (killed / background / locked / offline / reconnect scenarios, Hari-verified "working perfect"). This is the reference for how an inbound call reaches the Astradial iOS app in every app state, and how to debug it. History + individual bugs: troubleshooting Errors 78–83.
Why this exists¶
iOS kills backgrounded VoIP sockets; a killed/backgrounded app is not registered. Apple's only sanctioned wake is a PushKit VoIP push, and iOS 13+ requires every such push to immediately report a CallKit call. We run Asterisk (not Flexisip), so the push pipeline is ours end-to-end: platform API + APNs + dialplan hook + app.
Call flow (wake decision v2 — PRs #494…#497)¶
Caller dials extension
└─ dialplan (per-user, generated; gated on voip_push_token + INTERNAL_API_URL)
Ringing() ← caller hears ringback during decision
WAKERES = CURL /api/v1/asterisk/voip-push-wait?endpoint=…&plain=1&caller=…
│ server: ALWAYS sends VoIP push {caller, push-id}, apns-expiration:0
│ ┌ qualify-verify: cached "reachable" → pjsip qualify → re-read after 3.5s
│ │ alive → "yes" (reason verified)
│ ├ app ack of push-id ≤5s (POST /api/v1/users/voip-push-ack)
│ │ → poll for its re-REGISTER ≤15s → "yes" (reason acked)
│ └ neither → "no" (reason no-ack) — total ~5-6s
GotoIf($["${WAKERES}"="yes"]?available) ← trusts wake over stale DEVSTATE
Set(DEVSTATE…) → NOT_INUSE/engaged → (available) Dial ; UNAVAILABLE → failover
App side on push (PushKitManager, main queue): ① POST the push-id ack (before any core work — the delivery receipt APNs doesn't provide), ② report the CallKit call immediately (caller number from the push payload), ③ begin the wake keepalive (incomingCallWakeInProgress — CoreContext must not stop the Core mid-wake), ④ refreshRegisters() (NEVER processPushNotification(nil) — the empty call-id trips liblinphone's pushkit dedup and kills the wake). When the INVITE lands (.IncomingReceived): re-key the "" placeholder to the real call-id, then terminate liblinphone's own pushkit phantom session (unmergeable because our push has no call-id; it otherwise blocks accept ~10s — DEF-007).
Registration honesty¶
- Server AOR:
maximum_expiration=300,minimum_expiration=30,qualify_frequency=60,qualify_timeout=3.0. - App Settings → SIP Registration: 30s/1m/2m/5m (default 5m); changing it forces an immediate re-REGISTER.
- Token lifecycle: app POSTs
/users/voip-push-tokenon SIP attach (and on everydidUpdate/token refresh — the endpoint must be known first, so the app re-sends the cached token once a SIP line attaches). Token presence flip triggers an org regen (else the wake CURL wouldn't exist until an unrelated deploy). Body:
{ "asterisk_endpoint": "<PJSIP endpoint>", "token": "<hex token>",
"environment": "dev|prod", "bundle_id": "<app id>",
"platform": "ios|android" }
Cross-platform: iOS (APNs) & Android (FCM)¶
The token endpoint is platform-aware (server server.js voip-push-token): platform is stored as voip_push_platform and the wake gateway picks the sender — android → FCM data message, anything else → APNs VoIP push. The wake decision (ack race, qualify verify, failover) is identical; only the transport differs. bundle_id is the APNs topic (<bundle_id>.voip) on iOS and ignored on Android.
- iOS sends
platform:"ios"(VoIP/PushKit token) — live. - Android sends
platform:"android"(FCM registration token) — the app is in build (astracall-android-plan.md); the server FCM senderservices/fcmService.sendDataPushis a stub untilFCM_SERVICE_ACCOUNT_JSONis set and the FCM HTTP v1 send is wired. Until then android tokens store fine but the gateway falls through to cached SIP state (same graceful path as APNs-down) — Android calls don't fail to the phone number, they just don't wake-ring yet.
Tunables (api .env)¶
| Var | Default | Meaning |
|---|---|---|
VOIP_PUSH_ACK_TIMEOUT_MS | 5000 | ack race window |
VOIP_PUSH_TIMEOUT_MS | 15000 | re-REGISTER wait after ack |
APNS_KEY_ID/TEAM_ID/KEY_PATH/BUNDLE_ID | — | .p8 auth (topic <bundle>.voip) |
INTERNAL_API_URL/KEY | — | dialplan → api hook |
Debugging quick refs¶
- Dialplan decision:
grep "VoIP wake:" /var/log/asterisk/full.log→yes|no. - API decision reason: pm2 logs →
verified/acked/no-ack/acked-noregister/push-4xx(4xx incl. 410 = dead token). - App: Console.app, process
Astradial, Include Info Messages; look for[PushKitManager] Incoming VoIP push→Push ack sent→ REGISTER 200 →IncomingReceived. NSLog⏱️markers don't survive RTF export. - Token per device:
users.voip_push_token(envdev=sandbox Debug builds,prod=Release). Wrong env → APNs 400 BadDeviceToken. - Fake call on reconnect = something re-introduced pushes without
apns-expiration: 0.
Known limits / ceilings¶
- Push acks: in-memory map, single pm2 fork — move to a shared store if the api is ever clustered.
- Wake adds ~3.5s (verify) pre-ring for registered-but-unconfirmed contacts; ack path is ~1-3s. All behind ringback.
pjsip qualifyverification needs qualify enabled (nonqual contacts are trusted as-is — e.g. Alphion ONTs, which don't answer OPTIONS anyway).