Skip to content

feat: keep the iOS system call screen from taking over on simultaneous incoming calls (WT-1678) - #354

Draft
SERDUN wants to merge 12 commits into
developfrom
feat/ios-deferred-callkit-registration
Draft

feat: keep the iOS system call screen from taking over on simultaneous incoming calls (WT-1678)#354
SERDUN wants to merge 12 commits into
developfrom
feat/ios-deferred-callkit-registration

Conversation

@SERDUN

@SERDUN SERDUN commented Aug 10, 2026

Copy link
Copy Markdown
Member

Overview

When two incoming calls ring at the same time on iOS and the user answers one of them, the system takes over with a full-screen call-waiting UI for the other call, leaving the app's own call screen in a wrong or incomplete state. In some flows the second call was not visible or audible at all.

The iOS plugin now keeps at most one call registered with the system while another one is being answered: the extra ringing call is held back and re-entered afterwards, so the system screen never appears and both calls stay manageable from the app. Verified on a real device with repeated dual-incoming-call cycles (no system screen, no stuck calls).

Known trade-off: the re-entered call is recorded in the system call log as outgoing. Draft until productization is done (diagnostics cleanup, opt-in flag, native tests).

SERDUN added 12 commits August 4, 2026 09:53
… answered

Answering one of two ringing incoming calls used to leave the other one
registered, so iOS covered the app with its full-screen call-waiting
prompt. Now the plugin takes the remaining ringing call out of the
system for that moment: it keeps ringing inside the app and re-enters
the system flow the instant it is answered, from whichever UI the
answer came. Late pushes and catching-up signaling can no longer
resurrect it. Verified by compiling the app against the branch.
The deferred-path logs and the exact transaction error now print in any
build mode, so device sessions launched without Flutter tooling still
show which stage failed. Temporary instrumentation.
The report completion may arrive off the main thread; mutating the
plugin's call bookkeeping there raced the observer callback and could
crash the app right before the answer was requested.
…erred calls

A deferred call now re-enters CallKit under a brand-new identifier: the
system happily accepts a re-report on the old one but hands back a call
that often cannot be answered, leaving its screen stuck. A boundary
translation keeps the Flutter side on the stable identifier. Deferral
also now triggers on arrival - an incoming call while any call already
lives in CallKit rings only inside the app, so the system screen no
longer appears when a second call comes during a conversation.
The bookkeeping set can retain an ended call the observer never showed
as ended, which made every following incoming call defer forever - the
first call then rang only inside the app instead of through the system.
Re-reporting it as incoming raised the system incoming screen over the
app and iOS kept the user on its own in-call screen after the answer.
Started as outgoing, the call goes active silently and the user stays in
the app; the start action is fulfilled inside the plugin and reaches
Flutter as a plain answer of the original call. System call log shows
such calls as outgoing - accepted trade-off.
When the push and signaling paths race to report the same incoming call,
the losing path could mistake the call's own registry entry for a
competing live call and mark it deferred. Declining then left a ghost
ringing entry in CallKit, and answering brought up the system
call-waiting screen instead of staying in the app.

A call already live in CallKit is now always handled by the normal
CallKit actions, and a stale deferred mark is cleared before any
answer or end decision trusts it. Verified against the exact failing
sequence captured on a device.
The call observer reports an ended call as still live for a moment after
the plugin ends it. In that window the deferred-registration guards could
mistake the fading entry for a real registration: a freshly deferred call
lost its deferred mark, its answer then targeted a dead CallKit entry,
and hold/end requests failed with unknown-call errors.

Calls the plugin has reported ended are now remembered until the
observer confirms the end, and every liveness decision skips them.
The mechanism is now opt-in via an option flag and off by default, so
apps keep the standard iOS call-waiting behavior until they enable it.

A deferred call also stops being a second-class one: the in-call
call-waiting tone announces it during a conversation, an unanswered one
leaves the usual missed-call record and notification, and when the
visible call ends while a deferred one still rings, it surfaces back to
the lock screen and ringtone instead of dying silently.
Review follow-up for the deferred CallKit registration mechanism:

- A failed answer no longer strands calls: the re-attach rolls its alias
  state back when CallKit refuses the transaction, and calls deferred for
  an answer that then fails are brought back into the system UI.
- Registration paths dedup against the promoted alias of a call instead
  of registering a duplicate ringing entry.
- The defer pass skips entries already being ended, ended-report
  bookkeeping is pruned once the registry forgets the entry, and
  tearDown clears all mechanism state.
- The missed-call journal entry waits until no call is live (no prompt
  flash next to an active call), is written only for calls without an
  earlier journal record, and its throwaway entry is never answerable.
- Caller-name updates now reach the cached metadata used for re-entry
  and journaling.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant