CallKit VoIP call on the receiver: the side (lock) button only locks the screen, no CXEndCallAction is delivered

Device and versions

  • iPhone 15, iOS 27.0.1
  • App: Hush, a private messenger. Capacitor 8.5.2 iOS app; the UI is a web page in a WKWebView. Call signalling runs in the page; call audio runs in a native Swift Capacitor plugin on stasel/WebRTC 154.0.0, integrated with CallKit. Built on Codemagic with the latest Xcode, distributed through TestFlight. UIBackgroundModes: audio, voip. No PushKit yet: calls are placed and answered in the app's own screen while it is in the foreground.

Setup

  • RTCAudioSession manual audio (useManualAudio = true). isAudioEnabled is set only after ICE connects AND provider(_:didActivate:) is called (a 3-second fallback exists but never fired in these tests). We call RTCAudioSession.audioSessionDidActivate / audioSessionDidDeactivate from the provider delegate.
  • Session: playAndRecord, mode voiceChat, options allowBluetoothHFP, no defaultToSpeaker. overrideOutputAudioPort is only called when the user picks Speaker (never in the failing tests).
  • One CXProvider, created lazily, delegate on the main queue. Configuration: supportsVideo false (also tested true), maximumCallGroups 1, maximumCallsPerCallGroup 1, supportedHandleTypes [.generic], includesCallsInRecents false (also tested true).
  • Outgoing: CXStartCallAction is requested via CXCallController when ICE first connects (not at dial time; the page plays its own ringback until then), handle generic, value "Hush". In perform(CXStartCallAction) we configure the audio session (no activation), reportCall(with:updated:), fulfil, then reportOutgoingCall(startedConnectingAt:) and (connectedAt:).
  • Answering side: reportNewIncomingCall, then an immediate CXAnswerCallAction (the user already accepted in the app's screen).
  • Mute: CXSetMutedCallAction. In-app End reported the call with reportCall(with:endedAt:reason: .remoteEnded) in all tests below (we are changing this to a CXEndCallAction request; not yet tested).
  • Proximity: isProximityMonitoringEnabled = true from dialling while on the receiver. The screen does go dark at the ear. Also tested with our proximity monitoring off.

The problem
With a Hush call live, route Receiver, CallKit holding the call (the system call UI shows it), didActivate delivered exactly once, our audio running inside that activation, proximity on and the screen dark at the ear: pressing the side button only locks the screen. No CXEndCallAction arrives and the call continues. In the Phone app on the same iPhone the side button ends the call at the ear. Snapchat calls on this iPhone end on the side button while Snapchat's call screen is open.

What works: End on the system lock-screen call UI delivers a CXEndCallAction and we end the call. Mute from the system UI arrives as CXSetMutedCallAction. So provider delegate delivery works.

What we tried, in order (each tested on device, with a readout of the plugin's state after the call)

  1. First CallKit build: side button did nothing; lock-screen End worked. Readout: "CallKit off, its audio off (0x), fallback no, route speaker, proximity on". Cause: CXStartCallAction was refused (missing voip background mode) and the error swallowed. Fixed: voip added; on CXErrorCodeRequestTransactionError.unknownCallProvider we invalidate and recreate the provider and retry once.
  2. Next build: CallKit held the call, didActivate once, route Receiver. Side button still did nothing, no CXEndCallAction. Our route log showed two route changes caused by our own overrideOutputAudioPort(.none) on every activation and route change. Removed: no override unless Speaker is selected. Result: no change.
  3. Diagnostic switches, tested in combinations: our proximity monitoring on/off; supportsVideo false/true; includesCallsInRecents false/true; callee now reported with reportNewIncomingCall + CXAnswerCallAction (previously both sides used CXStartCallAction). Result: the side button ended the call EXACTLY ONCE: the first call after the provider was recreated (changing supportsVideo makes us invalidate() and create a new CXProvider). Every later call with the same configuration failed. Readout for the failures: "CallKit on, its audio on (1x), fallback no, route Receiver, proximity on", screen dark at the ear.
  4. Following thread 837211 (creating a provider implicitly pushes the audio session ID to callservicesd, which can keep a stale one; set the configuration before reporting each call): we now assign provider.configuration = a fresh CXProviderConfiguration at call start and again immediately before each CXStartCallAction request / reportNewIncomingCall. We also added a switch that recreates the CXProvider for every call. With the re-push alone: no end action. With a fresh provider every call: no end action either. Readout each time: "provider calls 1, config re-pushed yes, CallKit on, its audio on (1x), route Receiver, proximity on". The single success in step 3 has not been reproduced.
  5. Not tried on purpose: detecting the lock ourselves (protectedDataWillBecomeUnavailable arrives about 10 seconds late and needs a passcode; a 2017 thread says Home and Lock cannot be told apart); reporting the CallKit call at dial time instead of ICE connect; PushKit.

Threads we relied on

  • 819363 (April 2026): the lock button hangs up a CallKit call handled directly by the device (not through a headset), delivered as the provider's end action.
  • 837211 (August 2026): stale audio session ID in callservicesd after provider creation; set the configuration before reporting a call.

How we differ from apps where it works

  • WKWebView: WebKit's GPU process holds a second AVAudioSession in our app's name. During the call the page captures no mic (the plugin owns it); before connect the page plays a Web Audio ringback (Ambient category).
  • The CallKit call starts at ICE connect, not at dial time.
  • In-app End reported as .remoteEnded rather than requested as a CXEndCallAction.
  • The callee's immediate CXAnswerCallAction after reportNewIncomingCall.

Questions

  1. Which conditions must hold for iOS to treat the side button as "end call" for a CallKit call, and which is our app failing? Is there a log subsystem or category (callservicesd, TelephonyUtilities) we can read in a sysdiagnose to see why the press is handled as lock?
  2. Could WebKit's GPU-process audio session in the same app confuse CallKit about which session carries the call? Would reporting the call at dial time, before any audio, change the outcome?
  3. Can ending the in-app call with reportCall(.remoteEnded) instead of a CXEndCallAction leave state in callservicesd that affects the NEXT call? That would fit "works once after a fresh provider, never again".
  4. We have no Mac. Is a sysdiagnose captured on the iPhone right after a failing press enough to diagnose this? If you need a project, we can provide the plugin's Swift source (one file) and the Capacitor app project that Codemagic builds.