Inženjering9 min čitanja
Tri iOS pravila za aplikacije za pozive: VoIP push, CallKit i AVAudioSession
Ako VoIP push ne prijavite CallKit-u, iOS gasi aplikaciju. Ako pogrešite sa audio sesijom, poziv se uspostavi u tišini. Tri pravila iOS sloja ID Caller-a i nemi pozivi iz kojih smo ih naučili.
ID Caller je naša aplikacija za privatne audio i video pozive bez broja telefona (stranica projekta). Skoro ceo klijent je zajednički Kotlin sa UI-jem na Compose Multiplatform-u. iOS modul je tanak adapter prema PushKit-u, CallKit-u i audio sesiji. Koda u njemu ima malo, ali upravo tu iOS najstrože proverava aplikacije za pozive. Ako prekršite prvo pravilo, sistem gasi aplikaciju i može prestati da joj isporučuje VoIP push poruke. Ako prekršite druga dva, poziv se uspostavi, ali niko ništa ne čuje.
Ova pravila imamo zapisana: prvo je invarijanta 5 u pravilima projekta za agente, a druga dva su lekcije sa terenskih testova. Za svako objašnjavamo šta Apple zahteva i zašto, šta se kod nas kvarilo i kako kod izgleda sada.
Gde živi iOS sloj za pozive
Sva logika poziva (mašina stanja za 1:1, grupni mesh, signalizacija) nalazi se u mobile/shared. Na iOS-u između nje i sistema stoje dva Swift fajla. AppDelegate.swift je zadužen za pokretanje procesa i predaju zvuka. CallKitBridge.swift drži logiku PushKit-a i CallKit-a iza protokola, pa je možemo testirati bez pravog CXProvider-a i bez Kotlin frameworka.
Redosled pokretanja je bitan. iOS može da probudi ubijenu aplikaciju VoIP push porukom, i tada se scena ne kreira. Sve što je potrebno handleru push poruke mora biti spremno u didFinishLaunching:
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// ...
// Ubijena aplikacija probuđena VoIP push porukom ne kreira scenu:
// Koin graf mora da se podigne ovde, inače prihvatanje iz CallKit-a ne radi.
MainViewControllerKt.ensureKoin()
// Ručni audio: WebRTC ne dira audio jedinicu do eksplicitnog enable
// (CallKit didActivate, ili odgovor/poziv van CallKit-a).
RTCAudioSession.sharedInstance().useManualAudio = true
let controller = CallKitController()
callKit = controller
// ...
let registry = PKPushRegistry(queue: .main)
registry.delegate = controller
registry.desiredPushTypes = [.voIP]
voipRegistry = registry
return true
}I Apple-ov vodič Responding to VoIP Notifications from PushKit kaže da se objekat providera kreira rano u životnom ciklusu aplikacije. Naš se kreira unutar CallKitController-a, baš ovde.
Pravilo 1: svaki VoIP push postaje CallKit poziv pre completion
Šta Apple zahteva: od iOS 13 SDK-a VoIP push poruke iz PushKit-a služe samo za pozive. U metodi pushRegistry(_:didReceiveIncomingPushWith:for:completion:) iz PKPushRegistryDelegate aplikacija mora da prijavi poziv CallKit-u preko reportNewIncomingCall. Ako to ne uradi, sistem gasi aplikaciju, a posle ponovljenih propusta može potpuno da prestane da joj isporučuje VoIP push poruke. Za aplikaciju za pozive to je praktično ban: pozivi na telefon koji spava prestaju da stižu. Razlog je to što VoIP push budi čak i ubijenu aplikaciju, a Apple tu privilegiju daje samo u zamenu za sistemski ekran poziva. Ako CallKit ne možete da koristite, Apple predlaže obične notifikacije iz User Notifications frameworka umesto PushKit-a.
Naše pravilo je strože od „prijavi negde u handleru”. Prijava ide sinhrono, pre completion, za svaku push poruku, uključujući i onu koju ne možemo da parsiramo i duplikat:
func didReceiveVoipPush(_ payload: [AnyHashable: Any], completion: @escaping () -> Void) {
guard let push = IncomingCallPush.parse(payload) else {
reportFailedIncomingCall(callerName: "ID Caller")
completion()
return
}
guard !sessions.values.contains(where: { $0.callId == push.callId }) else {
// Čak i duplikat mora da prođe reportNew pre completion; sintetički
// poziv odmah gasimo da signalizaciju ne budimo ponovo.
reportFailedIncomingCall(callerName: push.fallbackCallerName, handle: push.fromId, video: push.video)
completion()
return
}
let uuid = UUID()
sessions[uuid] = Session(callId: push.callId, push: push)
rememberCallKitCall(push.callId)
let update = CXCallUpdate()
// Ime iz keša odmah, Room ga kasnije precizira.
update.localizedCallerName = gateway.cachedCallerName(fromId: push.fromId) ?? push.fallbackCallerName
update.hasVideo = push.video
update.remoteHandle = CXHandle(type: .generic, value: push.fromId)
reporter?.reportNewIncomingCall(uuid: uuid, update: update) { _ in }
gateway.incomingCallPushed(push)
completion()
// Ime iz Room-a: asinhrono, posle obavezne prijave.
gateway.callerName(fromId: push.fromId) { [weak self] name in
// ... reportCall(uuid:updated:) sa imenom kontakta
}
}reportFailedIncomingCall prijavljuje poziv i odmah ga završava sa .failed. Korisnik u najgorem slučaju vidi kratak bljesak, a dogovor sa PushKit-om je ispoštovan. Sve što može da potraje, poput čitanja imena kontakta iz lokalne baze ili povezivanja na signalizaciju, ide posle prijave. Push ne nosi SDP. On samo budi WebSocket sesiju, a ona zatim preuzima baferovani offer sa servera.
Ovo pravilo je bilo rizik R-01 u našim discovery dokumentima pre nego što je postojao ijedan red iOS koda. Tiket u kome je građen PushKit most pokrio ga je XCTest-ovima, i danas testReportsCallKitImmediately, testMalformedPayloadStillReportsBeforeCompletion i testDuplicatePushStillReportsBeforeCompletion proveravaju redosled „prvo reportNew, pa completion” na lažnom CXProvider-u koji beleži pozive. Još jedan test čita Info.plist i pada ako u UIBackgroundModes nema voip ili audio.
Pravilo 2: zvuk živi uz poziv za koji CallKit zna
Šta Apple zahteva: za pozive za koje zna, CallKit je vlasnik AVAudioSession-a. Vi podesite kategoriju i ispunite (fulfill) akciju odgovora ili odlaznog poziva. Sistem zatim aktivira sesiju i javlja vam to u provider(_:didActivate:) iz CXProviderDelegate, i tek posle toga treba pokretati medije. Kada se završi poslednji poziv za koji CallKit zna, on deaktivira sesiju, šta god da vaša aplikacija još pušta kroz nju. Obe polovine ovog pravila naučili smo na sopstvenim greškama.
Kategorija pre fulfill
Petog jula radili smo terenski test sa ubijenim iPhone-om. Push je stigao, WebSocket se povezao i telefon je zazvonio za oko sekundu. Korisnik se javio sa zaključanog ekrana, a didActivate nikad nije stigao: bez unapred podešene kategorije CallKit nije aktivirao sesiju. U ispravci (16f20c5) kategorija se podešava u akciji odgovora pre fulfill(), a rezervni mehanizam forsira prihvatanje ako didActivate ne stigne za 3 sekunde:
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
guard sessions[action.callUUID] != nil else {
action.fail()
return
}
// Kategorija mora biti podešena PRE fulfill, inače CallKit ne
// aktivira audio sesiju i didActivate ne stiže.
gateway.prepareAudioSession()
sessions[action.callUUID]?.answering = true
pendingAnswers.insert(action.callUUID)
action.fulfill()
// Rezerva: didActivate ponekad ne stigne, pa forsiramo accept
// i ručno uključujemo zvuk (manual mode).
let uuid = action.callUUID
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self, self.pendingAnswers.contains(uuid) else { return }
self.activatePendingAnswers()
}
}Samu kategoriju podešava zajednički Kotlin preko AVFoundation bindinga u Kotlin/Native-u. Neuspeh se upisuje u log, jer bi bez podešene sesije poziv jednostavno ćutao i ničim drugim se ne bi odao:
private fun configureCallAudioSession() {
val applied = AVAudioSession.sharedInstance().setCategory(
category = AVAudioSessionCategoryPlayAndRecord,
mode = AVAudioSessionModeVoiceChat,
options = AVAudioSessionCategoryOptionAllowBluetooth,
error = null,
)
if (!applied) {
// Bez podešene sesije CallKit ne šalje didActivate i poziv ostaje
// nem: neuspeh mora da se vidi u dijagnostici.
idcLog("[idc-ck] audio session setCategory failed")
}
}Grupni poziv za koji CallKit nije znao
Devetog jula poziv 1:1 je eskaliran u grupni (mesh). Grupa je živela van CallKit-a. Kada se stari poziv 1:1 završio, CallKit je zatvorio jedini poziv za koji je znao i deaktivirao audio sesiju. Po logovima sa Android telefona mesh se podigao savršeno, ali su oba iPhone-a ćutala.
U ispravci (5a71acd) grupa emituje iste sistemske događaje kao poziv 1:1, pa je CallKit vidi:
fun groupCallSystemEvents(states: Flow<GroupCallState>): Flow<CallSystemEvent> = flow {
var previous: GroupCallState = GroupCallState.Idle
// ...
states.collect { next ->
val prevActive = previous as? GroupCallState.Active
when {
next is GroupCallState.Active && prevActive == null -> {
// ...
val ringing = previous as? GroupCallState.InviteRinging
if (ringing?.callId == next.callId) {
emit(CallSystemEvent.AnsweredLocally(next.callId))
} else {
val handle = next.members.firstOrNull()?.id ?: next.initiatorId
emit(CallSystemEvent.OutgoingStarted(next.callId, handle, false))
}
}
next !is GroupCallState.Active && prevActive != null ->
emit(CallSystemEvent.CallFinished(prevActive.callId))
// ...
}
// ...
previous = next
}
}Oba platformska mosta slušaju jedan spojeni tok događaja za 1:1 i grupu. Android je imao grešku iste vrste: njegov most je slušao samo pozive 1:1, grupa nije bila registrovana u Telecom-u, a njen zvuk je išao kao medijski tok kroz glasni zvučnik. Na Swift strani provider dozvoljava maximumCallGroups = 2, jer u trenutku eskalacije žive oba poziva. Sopstveni zvuk gasimo tek kada ne ostane nijedan živ poziv:
CallKitSyncBridge.shared.onCallFinished = { [weak controller] callId in
DispatchQueue.main.async {
let callKitOwned = controller?.isRegistered(callId: callId) ?? false
controller?.callFinished(callId: callId)
// Eskalacija 1:1 → grupa: završetak starog poziva NE sme
// da utiša zvuk žive grupe.
if controller?.hasActiveCalls != true {
AudioSessionHandoff.disable(deactivateSession: !callKitOwned)
}
}
}Pravilo 3: van CallKit-a sesiju aktivirajte sami, u celosti
WebRTC radi u ručnom režimu (useManualAudio = true), pa je njegova audio jedinica ugašena dok je mi ne uključimo. U suprotnom WebRTC može da pokrene zvuk pre nego što CallKit aktivira sesiju, i rezultat je povremena tišina. Obično je signal za uključivanje didActivate.
Ali neki pozivi didActivate nikad ne dobiju. Server šalje VoIP push za group.invite samo pozvanima koji su offline. Pozvani koji je online dobija poziv preko WebSocket-a, vidi naš sopstveni Compose ekran dolaznog poziva i tapne „Prihvati” u aplikaciji. CallKit poziva nema, pa nema ni didActivate. Isto važi i za odlazni poziv ako CallKit odbije CXStartCallAction. U tim slučajevima AppDelegate sam poziva AudioSessionHandoff.enable().
Nemi odgovor iz aplikacije
Dvadeset četvrtog jula testirali smo na iPhone 14 Pro Max-u. Zvuk 1:1 je prošao na svim putanjama, ali grupni poziv prihvaćen dugmetom u aplikaciji bio je bez zvuka. enable() je javljao WebRTC-u da je sesija aktivna i uključivao njegov zvuk. Ali niko nije podesio kategoriju niti pozvao setActive(true), pa je WebRTC „aktivirao” mrtvu sesiju. Ispravka (b25dfff) dodaje dva poziva koja su nedostajala:
static func enable() {
guard !active else { return }
active = true
+ let av = AVAudioSession.sharedInstance()
+ // Prijem van CallKit-a (dugme u Compose UI-ju): niko nije podesio
+ // kategoriju niti aktivirao sesiju, pa je audioSessionDidActivate
+ // „aktivirao” mrtvu sesiju. CallKit putanja ove pozive ponavlja idempotentno.
+ try? av.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth])
+ try? av.setActive(true)
let rtc = RTCAudioSession.sharedInstance()
- rtc.audioSessionDidActivate(AVAudioSession.sharedInstance())
+ rtc.audioSessionDidActivate(av)
rtc.isAudioEnabled = true
}Zastavica active čini enable() idempotentnim. Na putanji kroz CallKit poziva ga i didActivate. Tamo je kategorija već podešena, a sistem je sesiju već aktivirao, pa ponovljeni pozivi ne smetaju.
Oslobodite samo ono što ste sami aktivirali
Isti commit je naterao disable() da svaki put pozove setActive(false). U komentaru je pisalo da posle poziva kroz CallKit to ponavljanje ništa ne radi. Dvadeset četvrtog septembra pokazalo se da nije tako. Kada smo deaktivirali sesiju čiji je vlasnik CallKit, sledeći odlazni poziv u istom procesu ostajao je bez didActivate: prebacivanje na glasni zvučnik nije radilo, a tonovi video poziva išli su u slušalicu. Sada most pamti koji su pozivi prošli kroz CallKit, i sesiju sami oslobađaju samo pozivi van CallKit-a:
/// [deactivateSession]: samo za poziv van CallKit-a. Sesiju čiji je vlasnik
/// CallKit deaktivira sistem u didDeactivate; naš setActive(false) na njoj
/// kvario je CallKit za sledeći odlazni poziv u istom procesu.
static func disable(deactivateSession: Bool) {
guard active else { return }
active = false
queue.async {
let rtc = RTCAudioSession.sharedInstance()
rtc.isAudioEnabled = false
rtc.audioSessionDidDeactivate(AVAudioSession.sharedInstance())
if deactivateSession {
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
}
}
}queue je posebna ispravka (6b45c7a). Aktivacija sesije i pokretanje WebRTC audio jedinice trajali su oko 0,3 s na glavnoj niti, i HangTracer je to zabeležio na uređaju. Sada se oba koraka izvršavaju na serijskom redu. On čuva redosled enable i disable, a zastavica active ostaje na glavnoj niti.
Ček-lista
- Kreirajte
PKPushRegistryiCXProviderudidFinishLaunchingi tu podignite sve od čega zavisi handler push poruke. Buđenje VoIP push porukom ne kreira scenu. - Svaku VoIP push poruku prijavite preko
reportNewIncomingCallsinhrono, precompletion. To važi i za neispravne payload-e i za duplikate: prijavite ih, pa odmah završite sa.failed. - Spore poslove radite posle prijave, a poziv ažurirajte preko
reportCall(with:updated:). - Redosled proveravajte testom sa lažnim
CXProvider-om koji beleži pozive, aUIBackgroundModesna prisustvovoipiaudio. - Kategoriju
AVAudioSession-a podesite prefulfill()zaCXAnswerCallActioni imajte rezervu za slučaj dadidActivatene stigne. - Svaka vrsta poziva koja pušta zvuk mora biti poznata CallKit-u, uključujući grupne pozive i eskalacije. Podesite
maximumCallGroupsza prelazni trenutak. - Nikad ne pozivajte
setActive(false)na sesiji čiji je vlasnik CallKit. Sopstveni zvuk gasite tek kada ne ostane nijedan živ poziv. - Koristite ručni režim
RTCAudioSession-a i uključujte WebRTC zvuk tek kada je sesija zaista aktivna. - Na putanjama bez CallKit-a uradite sva tri poziva sami:
setCategory,setActive(true), paaudioSessionDidActivatesaisAudioEnabled. - Držite
setActivevan glavne niti, na serijskom redu. - Za App Review: VoIP push iz PushKit-a i CallKit ne zavise od dozvole za notifikacije, pa ulaz u aplikaciju ne uslovljavajte tom dozvolom. To je bio naš glavni argument u odgovoru Apple-u posle odbijanja.
ONLYAPPS Team
Novi Sad, Srbija · onlyapps.org