Two loops poll one mailbox: the service's, which keeps it watched while
nothing is on screen, and the view model's. An envelope goes to whichever
asks first -- the relay marks it delivered and does not hand it out again.
The service's handler knew what to do with an offer and an end and had
`else -> Unit` for everything else, so an ANSWER it happened to fetch was
thrown away. Measured, not inferred: an iPhone answered, the relay accepted
the answer, this side logged the envelope decrypting, and the call manager's
own "call signal in" line never appeared. The signal reached the process and
stopped one layer short of the call. The caller then sat on "Calling..." for
the full forty-five seconds and gave up, every time the race went that way.
An END fared no better: it cleared a notification and left a live call
running.
A call now registers itself so either loop can reach it, and the service
forwards every signal after its `when` rather than inside a branch of it --
in a branch it would cover only the actions somebody remembered, which is the
bug restated, so there is a test holding it outside.
A CALL CANNOT WAIT ON A TWENTY SECOND HOLD
The same loop asked the relay to hold every request open for twenty seconds.
Right for messages, wrong for the two signals somebody is watching a screen
for. iOS polls hard for the length of a call and this never had the same
thing, so an answer sent at once was accepted twenty seconds later: offer at
:07.9, answer accepted at :28.5, with the relay holding it the whole time.
It polls with no hold at a one second cadence while a call is up, and goes
back to the hold when there is not one. The same pair now measures 2.8s.
ENVELOPES THAT ARRIVE TWICE
`hasSeenEnvelope` is guarded on the id being present and nothing sent one on
the ordinary route, so it was asked about nothing. One offer on the relay was
delivered twice 2.4 seconds apart and the second was answered "busy" by the
phone ringing for the first. Every send carries an id now, with a default on
the field so a new call site cannot forget one, and an offer for the call this
phone is already on is no longer mistaken for a second caller.
CLOSING THE APP DURING A CALL
`onTaskRemoved` is what fires when the app is swiped out of Recents, and it
runs while the process is still alive, which is the point: by `onDestroy`
there may be no time left to reach the network. On a thread with a bounded
join rather than a coroutine, because the scope is cancelled a moment later
and a launched coroutine would be killed before the envelope left.
1606 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>