Files
flamenet-messenger-android/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt
T
Paul HittandClaude Opus 5.5 57a969d3f2 Nothing goes around the bridge: previews, calls and the wallet are off while one is saved
SPEC §24.6: a client with bridges turned on must not connect directly.

- Off while a line is saved, saying why in plain words: link previews (the
  fetch returns nothing and connects nowhere; the setting is greyed with a
  note), calls (refused at place, at answer and where the peer connection is
  built; an incoming call is answered busy), and the wallet (the chain client
  refuses before connecting; the screen says so).
- Through the bridge now: the update check, which had its own client.
- The anonymous client keeps its own pool, which now empties with the rest
  when the bridges change (§24.7).
- The probe the app makes at launch goes to a saved bridge, checked by a
  test that fails when the bridges are not loaded at startup. With no bridge
  it is unchanged.
- The sign-in UI test saves a dead line first, so its view model never
  probes the production server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:18:56 -04:00

1242 lines
48 KiB
Kotlin

package io.flamenet.messenger.app
import android.content.Context
import io.flamenet.e2e.Content
import org.json.JSONObject
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.Camera2Enumerator
import org.webrtc.DataChannel
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.EglBase
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpReceiver
import org.webrtc.SdpObserver
import org.webrtc.SessionDescription
import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoCapturer
import org.webrtc.VideoSource
import org.webrtc.VideoTrack
import java.util.UUID
/**
* Voice calls over WebRTC (spec §8.10).
*/
class CallManager(
private val context: Context,
/** Send one signal to the other party, down the ratchet. */
private val signal: (Long, Content.CallSignal) -> Unit,
private val onStateChanged: (State) -> Unit,
) {
enum class State { IDLE, DIALLING, RINGING, CONNECTING, ACTIVE, ENDED }
data class Active(
val callId: String,
val peerId: Long,
val incoming: Boolean,
val video: Boolean = false,
)
var current: Active? = null
private set
var state: State = State.IDLE
private set(value) {
field = value
onStateChanged(value)
}
private var factory: PeerConnectionFactory? = null
private var connection: PeerConnection? = null
private var audioSource: AudioSource? = null
private var audioTrack: AudioTrack? = null
// -- Video (§6.8v) -------------------------------------------------------
// One EGL context for the factory's codecs, the capturer and every
// renderer.
val eglBase: EglBase by lazy { EglBase.create() }
private var videoCapturer: VideoCapturer? = null
private var videoHelper: SurfaceTextureHelper? = null
private var videoSource: VideoSource? = null
var localVideoTrack: VideoTrack? = null
private set
var remoteVideoTrack: VideoTrack? = null
private set
/** Fired on the main thread when a remote video track appears or goes. */
var onVideoTracksChanged: (() -> Unit)? = null
private val main = android.os.Handler(android.os.Looper.getMainLooper())
/**
* Runs once, on the main thread, when ICE gathering completes, this is
* where the offer or answer is actually sent.
*/
private var onGathered: ((String) -> Unit)? = null
/**
* Sends whatever was gathered if COMPLETE never fires.
*/
private var gatherGuard: Runnable? = null
/** Gives up an unanswered outgoing call, like iOS's 45 s dial timeout. */
private var dialGuard: Runnable? = null
/**
* Stops an unanswered incoming call ringing forever.
*
* Every other state had a guard and this one did not, so a call nobody
* picked up stayed RINGING until the app was killed. Nothing showed it: the
* notification can be swiped off the top of the screen while the state
* behind it survives. The next caller then got "busy" from a phone that
* looked idle, and its owner had no way to find out why.
*
* The caller gives up after DIAL_TIMEOUT_MS. This end waits a little longer
* than that, so a ring is released by its own timer only when the caller's
* hang-up never arrived.
*/
private var ringGuard: Runnable? = null
/**
* Ends a call whose far end stopped answering the media.
*
* `call-end` is the polite path and usually arrives first. When it does not,
* because it was lost or the app was somewhere it could not be read, this is
* what notices. Short, because a call whose audio has stopped is over
* whatever the signalling says.
*/
private var goneGuard: Runnable? = null
private fun armGoneGuard() {
if (goneGuard != null) return
val guard =
Runnable {
goneGuard = null
if (state == State.ACTIVE || state == State.CONNECTING) {
android.util.Log.i("fnmsg-call", "far end gone, ending the call")
hangUp(notifyPeer = true, reason = "timeout")
}
}
goneGuard = guard
main.postDelayed(guard, GONE_TIMEOUT_MS)
}
private fun cancelGoneGuard() {
goneGuard?.let { main.removeCallbacks(it) }
goneGuard = null
}
private fun armRingGuard() {
cancelRingGuard()
val guard =
Runnable {
if (state == State.RINGING) {
android.util.Log.i(
"fnmsg-call",
"ring expired with no answer, releasing so the next call is not refused",
)
// Released here and nothing sent. The caller gave up on its
// own timer long before this fires, so telling it was
// redundant, and on an account signed in on more than one
// device it was destructive: this phone keeps ringing when
// the call was answered on another one, because nothing
// tells it, and a timeout end sent from here reached the
// caller mid-call with that other device and hung it up.
hangUp(notifyPeer = false, reason = "timeout")
}
}
ringGuard = guard
main.postDelayed(guard, RING_TIMEOUT_MS)
}
private fun cancelRingGuard() {
ringGuard?.let { main.removeCallbacks(it) }
ringGuard = null
}
/**
* Gives up a call that was answered but whose media never connected.
*/
private var connectGuard: Runnable? = null
private fun armConnectGuard() {
connectGuard?.let { main.removeCallbacks(it) }
val guard =
Runnable {
if (state == State.CONNECTING) hangUp(notifyPeer = true, reason = "timeout")
}
connectGuard = guard
main.postDelayed(guard, CONNECT_TIMEOUT_MS)
}
/** Main thread only. */
private fun sendWhenGathered(send: (String) -> Unit) {
onGathered = send
val guard = Runnable { deliverGatheredSdp() }
gatherGuard = guard
main.postDelayed(guard, GATHER_TIMEOUT_MS)
// Gathering can already be done, tiny SDPs on a fast network, and then
// COMPLETE fired before this registration and nothing else will.
if (connection?.iceGatheringState() == PeerConnection.IceGatheringState.COMPLETE) {
deliverGatheredSdp()
}
}
/** Main thread only. Idempotent, guard timeout and COMPLETE can both fire. */
private fun deliverGatheredSdp() {
val send = onGathered ?: return
onGathered = null
gatherGuard?.let { main.removeCallbacks(it) }
gatherGuard = null
// This `?: return` sent nobody anything, silently.
val sdp =
connection?.localDescription?.description ?: run {
android.util.Log.w(
"fnmsg-call",
"NOT SENDING the offer/answer: no localDescription after " +
"gathering (state=${connection?.iceGatheringState()}). " +
"The other side will never see this call.",
)
return
}
android.util.Log.i(
"fnmsg-call",
"sending signal after gathering, sdp=${sdp.length} bytes, " +
"state=${connection?.iceGatheringState()}",
)
send(sdp)
}
/**
* ICE servers, P2P-first.
*/
private fun iceServers(turn: JSONObject?): List<PeerConnection.IceServer> {
if (turn == null) {
return listOf(
PeerConnection.IceServer
.builder("stun:stun.l.google.com:19302")
.createIceServer(),
)
}
val urls = turn.optJSONArray("urls") ?: return emptyList()
val username = turn.optString("username")
val credential = turn.optString("credential")
return (0 until urls.length()).map { i ->
PeerConnection.IceServer
.builder(urls.getString(i))
.setUsername(username)
.setPassword(credential)
.createIceServer()
}
}
private fun ensureFactory(): PeerConnectionFactory {
factory?.let { return it }
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions
.builder(context)
.createInitializationOptions(),
)
return PeerConnectionFactory
.builder()
// Video codecs even for a voice call: the factory is built once per
// process, and a voice-only factory would make the first video call
// render nothing while looking connected.
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext))
.createPeerConnectionFactory()
.also { factory = it }
}
private fun open(
turn: JSONObject?,
peerId: Long,
callId: String,
video: Boolean,
): PeerConnection? {
val config =
PeerConnection.RTCConfiguration(iceServers(turn)).apply {
// ALL rather than RELAY: direct paths are tried, and TURN is
// the fallback.
iceTransportsType = PeerConnection.IceTransportsType.ALL
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
// Trickle is off: candidates are gathered before the offer is
// sent, so signalling is ONE encrypted envelope each way rather
// than a stream of them.
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
}
val observer =
object : PeerConnection.Observer {
override fun onIceCandidate(candidate: IceCandidate?) {
// The candidate TYPE is the whole diagnosis.
val sdp = candidate?.sdp ?: return
val kind = Regex("typ (\\w+)").find(sdp)?.groupValues?.get(1) ?: "?"
android.util.Log.i("fnmsg-call", "ice candidate: typ=$kind")
}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit
// Nothing may throw out of here, and nothing may call back into
// WebRTC from here.
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
android.util.Log.i("fnmsg-call", "ice connection -> $newState")
runCatching {
when (newState) {
PeerConnection.IceConnectionState.CONNECTED,
PeerConnection.IceConnectionState.COMPLETED,
-> {
cancelGoneGuard()
state = State.ACTIVE
}
// CLOSED is what our OWN close() produces.
PeerConnection.IceConnectionState.CLOSED -> {
Unit
}
// The far end went away. Transient by definition,
// WebRTC recovers from it often, so this is a grace
// rather than a hang-up: if it comes back the timer
// is cancelled below. Without it a lost `call-end`
// left the screen up until the dial guard gave up
// three quarters of a minute later.
PeerConnection.IceConnectionState.DISCONNECTED -> {
armGoneGuard()
}
PeerConnection.IceConnectionState.FAILED -> {
hangUp(notifyPeer = true)
}
else -> {
Unit
}
}
}
}
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
android.util.Log.i("fnmsg-call", "ice gathering -> $newState")
// Hop to the main thread rather than touching the
// connection from here, same JNI rule as above: no WebRTC
// calls, and no exceptions, from inside a WebRTC callback.
if (newState == PeerConnection.IceGatheringState.COMPLETE) {
main.post { runCatching { deliverGatheredSdp() } }
}
}
override fun onAddStream(stream: MediaStream?) = Unit
override fun onRemoveStream(stream: MediaStream?) = Unit
override fun onDataChannel(channel: DataChannel?) = Unit
override fun onRenegotiationNeeded() = Unit
override fun onAddTrack(
receiver: RtpReceiver?,
streams: Array<out MediaStream>?,
) {
// The other side's camera.
runCatching {
val track = receiver?.track()
if (track is VideoTrack) {
remoteVideoTrack = track
main.post { runCatching { onVideoTracksChanged?.invoke() } }
}
}
}
}
// The one place every call path builds its connection (§24.6).
if (CallPolicy.bridgeRefusal() != null) return null
val pc = ensureFactory().createPeerConnection(config, observer) ?: return null
val source = ensureFactory().createAudioSource(MediaConstraints())
val track = ensureFactory().createAudioTrack("fnaudio", source)
pc.addTrack(track, listOf("fnstream"))
audioSource = source
audioTrack = track
// Now, not at connect: the microphone is open from here, and Android 14
// silences it the moment the app leaves the foreground unless the
// service has said it is using it.
MessengerService.callMedia(context, video)
if (video) {
// Failure here downgrades to voice rather than killing the call, an
// emulator without a camera, or a denied permission mid-flow,
// should still connect and say something.
runCatching {
val cams = Camera2Enumerator(context)
val name =
cams.deviceNames.firstOrNull { cams.isFrontFacing(it) }
?: cams.deviceNames.firstOrNull()
if (name != null) {
val cap = cams.createCapturer(name, null)
val helper = SurfaceTextureHelper.create("fncap", eglBase.eglBaseContext)
val vs = ensureFactory().createVideoSource(cap.isScreencast)
cap.initialize(helper, context, vs.capturerObserver)
cap.startCapture(960, 540, 24)
val vt = ensureFactory().createVideoTrack("fnvideo", vs)
val sender = pc.addTrack(vt, listOf("fnstream"))
// A cap, so video through the shared TURN cannot saturate
// the relay host. ~800 kbps is comfortable 540p.
runCatching {
val params = sender.parameters
params.encodings.forEach { it.maxBitrateBps = 800_000 }
sender.parameters = params
}
videoCapturer = cap
videoHelper = helper
videoSource = vs
localVideoTrack = vt
main.post { runCatching { onVideoTracksChanged?.invoke() } }
}
}
}
connection = pc
current = Active(callId, peerId, incoming = false, video = video)
return pc
}
/** Place a call. The offer is gathered, then sent as one encrypted envelope. */
fun call(
peerId: Long,
turn: JSONObject?,
video: Boolean = false,
) {
// Supervision: enforced where the call would start, not where the
// button is drawn, every entry point hits the same wall.
if (ParentalControls.callsDisabled(context)) return
if (CallPolicy.bridgeRefusal() != null) return
if (state != State.IDLE && state != State.ENDED) return
val callId = UUID.randomUUID().toString()
// `current` BEFORE the state change.
current = Active(callId, peerId, incoming = false, video = video)
state = State.DIALLING
val pc =
open(turn, peerId, callId, video) ?: run {
current = null
state = State.IDLE
return
}
pc.createOffer(
object : SimpleSdpObserver() {
override fun onCreateSuccess(sdp: SessionDescription) {
pc.setLocalDescription(SimpleSdpObserver("offer:setLocal"), sdp)
// The complete SDP, with candidates, exists only after
// gathering; see onGathered.
main.post {
sendWhenGathered { full ->
signal(
peerId,
Content.CallSignal(
Content.CallSignal.OFFER,
callId,
full,
video = video,
renegotiates = true,
),
)
}
}
}
},
MediaConstraints(),
)
// Give up out loud.
val guard =
Runnable {
if (state == State.DIALLING) hangUp(notifyPeer = true, reason = "timeout")
}
dialGuard = guard
main.postDelayed(guard, DIAL_TIMEOUT_MS)
}
/** An offer arrived. Held as ringing until a person accepts it. */
private var pendingOffer: Pair<Long, Content.CallSignal>? = null
/**
* A signal arrived down the ratchet.
*/
init {
live = this
}
fun onSignal(
fromUser: Long,
incoming: Content.CallSignal,
turn: JSONObject?,
sentAt: Long = 0,
) {
live = this
android.util.Log.i(
"fnmsg-call",
"call signal in: ${incoming.action} from $fromUser state=$state",
)
if (incoming.callId in endedCallIds) {
android.util.Log.i("fnmsg-call", "signal for ${incoming.callId} dropped: already over")
return
}
// Another of this account's own devices, saying the call ringing here
// was answered or declined there. Nobody on a call sends as this
// account, so this is never the caller.
val own = ownUserId()
if (own != null && fromUser == own) {
onOwnDevice(incoming)
return
}
when (incoming.action) {
Content.CallSignal.OFFER -> {
// Supervision: a device with calls off does not ring, the
// caller is answered busy, like any other unavailable state.
if (ParentalControls.callsDisabled(context) || CallPolicy.bridgeRefusal() != null) {
runCatching {
signal(
fromUser,
Content.CallSignal(Content.CallSignal.END, incoming.callId, reason = "busy"),
)
}
return
}
// An offer older than the caller's own patience is not a call.
if (sentAt > 0 && System.currentTimeMillis() - sentAt > DIAL_TIMEOUT_MS) {
android.util.Log.i(
"fnmsg-call",
"offer from $fromUser dropped: ${System.currentTimeMillis() - sentAt}ms old",
)
onStaleOffer?.invoke(fromUser, sentAt)
// Once only. A second delivery of the same dead offer
// would file a second missed call for one ring.
noteEnded(incoming.callId)
return
}
// §6.30, an offer for the call this device is already on is a
// handoff: the person on the other end moved to another of
// their devices. It is answered where it stands. No ring, no
// second entry in the call log, and the call screen stays.
if (incoming.callId == current?.callId &&
(state == State.CONNECTING || state == State.ACTIVE) &&
incoming.sdp != null
) {
renegotiate(fromUser, incoming, turn)
return
}
// The SAME call arriving again is not a second caller.
//
// Two loops poll one mailbox and they race on the inbox
// cursor, so an envelope fetched by one can still be handed to
// the other: measured, one offer on the relay and two
// deliveries here, 2.4 seconds apart. The second landed while
// this phone was ringing for the first, fell into the check
// below, and replied "busy" -- to the very call it was ringing
// for. The caller was told the callee was busy by a phone that
// was ringing at them.
//
// Closing the race is worth doing on its own, but a signal
// arriving twice has to be harmless whatever the transport
// does, so this is the check that belongs here.
if (incoming.callId == current?.callId) {
android.util.Log.i(
"fnmsg-call",
"offer for ${incoming.callId} again, already on it at state=$state",
)
return
}
// One call at a time. A DIFFERENT call, now genuinely.
if (state != State.IDLE && state != State.ENDED) {
android.util.Log.i(
"fnmsg-call",
"offer ${incoming.callId} answered busy: on ${current?.callId} at $state",
)
signal(
fromUser,
Content.CallSignal(Content.CallSignal.END, incoming.callId, reason = "busy"),
)
return
}
peerRenegotiates = incoming.renegotiates
pendingOffer = fromUser to incoming
current =
Active(
incoming.callId,
fromUser,
incoming = true,
video = incoming.video,
)
endedElsewhere = false
state = State.RINGING
armRingGuard()
}
Content.CallSignal.ANSWER -> {
val sdp = incoming.sdp
if (sdp == null) {
android.util.Log.i("fnmsg-call", "answer for ${incoming.callId} carried no sdp")
return
}
// An answer for some earlier call, a retry raced a hang-up,
// must not be installed into this one's connection.
//
// This returned in silence, and a dropped answer looks from the
// outside exactly like an answer that never arrived: the caller
// sits on "Calling..." until the dial guard gives up 45 seconds
// later. Saying which id was expected is the difference between
// a minute of guessing and a line in the log.
if (incoming.callId != current?.callId) {
android.util.Log.i(
"fnmsg-call",
"answer for ${incoming.callId} ignored: current is " +
"${current?.callId} state=$state",
)
return
}
android.util.Log.i("fnmsg-call", "answer accepted for ${incoming.callId}")
peerRenegotiates = incoming.renegotiates
dialGuard?.let { main.removeCallbacks(it) }
dialGuard = null
cancelRingGuard()
state = State.CONNECTING
armConnectGuard()
connection?.setRemoteDescription(
SimpleSdpObserver(),
SessionDescription(SessionDescription.Type.ANSWER, sdp),
)
}
Content.CallSignal.REACTION -> {
// For the call in progress and no other, and never filed
// anywhere: once the call ends there is no trace of it.
if (incoming.callId != current?.callId) return
if (state != State.CONNECTING && state != State.ACTIVE) return
incoming.emoji?.takeIf { it.isNotBlank() }?.let { emoji ->
main.post { runCatching { onReaction?.invoke(emoji) } }
}
}
Content.CallSignal.END -> {
// An end for some other call must not hang up this one. This
// branch used to hang up on any end at all, for any call id.
if (incoming.callId != current?.callId) {
android.util.Log.i(
"fnmsg-call",
"end for ${incoming.callId} ignored: not the current call",
)
// Very often it is a call that has not arrived yet:
// envelopes are fetched in batches, so a caller's hang-up
// can be decrypted before their offer. Remembering it is
// what stops that offer from ringing a moment later for a
// call that is already over, a ring nobody can answer
// because nobody is on the other end.
noteEnded(incoming.callId)
return
}
// A call answered on one of the callee's devices is still
// ringing on the others, and they all share this call id. Once
// this call has connected, an end whose reason means the ring
// was never picked up came from one of those other devices and
// is not about the call in progress: somebody silencing their
// tablet, or a ring guard from a build that still announces its
// timeout. Honouring it hung up a live call. A plain hang-up
// carries no such reason and still ends it.
val neverPickedUp = incoming.reason.orEmpty() in setOf("declined", "busy", "timeout")
if (neverPickedUp && (state == State.CONNECTING || state == State.ACTIVE)) {
android.util.Log.i(
"fnmsg-call",
"'${incoming.reason}' end ignored: this call was already picked up",
)
return
}
hangUp(notifyPeer = false)
}
}
}
/** An offer arrived too old to ring. The view model records the missed call. */
var onStaleOffer: ((Long, Long) -> Unit)? = null
/**
* A reaction arrived during the call. Fired on the main thread, and the
* screen is expected to show it briefly and forget it.
*/
var onReaction: ((String) -> Unit)? = null
/**
* Send one reaction to whoever is on the call. Dropped when there is no
* call to send it into: a reaction is for a conversation in progress.
*/
fun sendReaction(emoji: String) {
val active = current ?: return
if (state != State.CONNECTING && state != State.ACTIVE) return
if (emoji.isBlank()) return
runCatching {
signal(
active.peerId,
Content.CallSignal(Content.CallSignal.REACTION, active.callId, emoji = emoji),
)
}
}
/** This account's user id, read when a signal arrives. */
var ownUserId: () -> Long? = { null }
/**
* Sends one signal to this account's other devices. Best effort: a failure
* here must never reach the call on this device.
*/
var tellOwnDevices: ((Content.CallSignal) -> Unit)? = null
/** The ring that just ended was answered or declined on another device. */
var endedElsewhere = false
private set
/** Calls already over. A signal for one of these is dropped, never re-rung. */
private val endedCallIds = LinkedHashSet<String>()
/**
* §6.30, whether the peer on the call in progress said it can accept a
* renegotiating offer. A handoff is not offered otherwise.
*/
private var peerRenegotiates = false
/**
* §6.30, a call one of this account's own devices is offering to hand over.
*/
data class HandoffOffer(
val callId: String,
val peerId: Long,
val video: Boolean,
)
/** The offer standing right now, or null. */
var handoffOffer: HandoffOffer? = null
private set
/** Fired on the main thread when an offer appears or lapses. */
var onHandoffOffer: ((HandoffOffer?) -> Unit)? = null
private var handoffLapse: Runnable? = null
/**
* How long an offer to take a call stands. Short on purpose: a handoff that
* half happens has to leave the original working.
*/
private val handoffWindowMs = 8_000L
/**
* Remember a call as over. Bounded oldest-first: a long-running app must
* not keep every call id it has ever seen.
*/
private fun noteEnded(id: String) {
if (id.isEmpty() || !endedCallIds.add(id)) return
while (endedCallIds.size > 200) {
endedCallIds.remove(endedCallIds.first())
}
}
/**
* An end from another of this account's devices, which answered or
* declined the call ringing here. The answer goes to the caller alone, so
* before this the ring here carried on for its full minute and was then
* filed as a missed call somebody had taken.
*/
private fun onOwnDevice(incoming: Content.CallSignal) {
// §6.30, one of this account's devices offering the call it is on.
if (incoming.action == Content.CallSignal.HANDOFF) {
val peer = incoming.peer
if (state != State.IDLE && state != State.ENDED || peer == null) {
android.util.Log.i("fnmsg-call", "handoff offer ignored: this device is busy")
return
}
val offer = HandoffOffer(incoming.callId, peer, incoming.video)
handoffOffer = offer
main.post { runCatching { onHandoffOffer?.invoke(offer) } }
handoffLapse?.let { main.removeCallbacks(it) }
// The offer lapses rather than being withdrawn. A withdrawal can be
// lost; a timeout at both ends cannot, and the call stays where it
// is either way.
val lapse = Runnable { clearHandoffOffer() }
handoffLapse = lapse
main.postDelayed(lapse, handoffWindowMs)
return
}
// §6.30, another device took the call. This one lets go without telling
// the peer anything: an end would hang up a call that is now running
// perfectly well somewhere else.
if (incoming.action == Content.CallSignal.HANDOFF_TAKEN) {
if (incoming.callId != current?.callId) return
clearHandoffOffer()
endedElsewhere = true
hangUp(notifyPeer = false, reason = "moved")
return
}
if (incoming.action == Content.CallSignal.END &&
handoffOffer?.callId == incoming.callId
) {
clearHandoffOffer()
}
// Only a ring stops. A device already on the call ignores it: both
// answering at once is the one way it arrives, and whichever answer
// reached the caller first has the call.
if (incoming.action != Content.CallSignal.END ||
state != State.RINGING ||
incoming.callId != current?.callId
) {
android.util.Log.i(
"fnmsg-call",
"own-device ${incoming.action} for ${incoming.callId} ignored: nothing ringing for it",
)
return
}
endedElsewhere = true
hangUp(notifyPeer = false, reason = incoming.reason)
}
/**
* The far end moved to another of their devices and sent a fresh offer for
* the call already in progress. Answered where it stands.
*/
private fun renegotiate(
fromUser: Long,
offer: Content.CallSignal,
turn: JSONObject?,
) {
android.util.Log.i("fnmsg-call", "renegotiating ${offer.callId}: the far end moved device")
// A new connection rather than the old one: the far end is a different
// device with different ICE candidates and a different DTLS
// fingerprint, so there is nothing in the old one worth keeping.
connection?.let { runCatching { it.dispose() } }
connection = null
val pc =
open(turn, fromUser, offer.callId, offer.video) ?: run {
hangUp(notifyPeer = true, reason = "failed")
return
}
current = Active(offer.callId, fromUser, incoming = true, video = offer.video)
state = State.CONNECTING
armConnectGuard()
pc.setRemoteDescription(
SimpleSdpObserver("renegotiate:setRemote"),
SessionDescription(SessionDescription.Type.OFFER, offer.sdp),
)
answerNow(pc, fromUser, offer)
}
// ---- Handing the call over (§6.30) ------------------------------------.
/**
* Whether this call can be handed to another of this account's devices.
*
* Two conditions, both about not breaking a working call: the call has to
* be up, and the far end has to have said it accepts a renegotiating offer.
* A build that predates handoff never says so, and finding out by dropping
* the call is the wrong way to find out.
*/
fun canHandOff(): Boolean {
val active = current ?: return false
if (!peerRenegotiates) return false
return state == State.CONNECTING || state == State.ACTIVE && active.callId.isNotEmpty()
}
/**
* Offer this call to this account's other devices. Nothing is torn down and
* the peer is sent nothing: if no device takes it, the call carries on
* exactly where it is.
*/
fun offerHandoff() {
if (!canHandOff()) return
val active = current ?: return
runCatching {
tellOwnDevices?.invoke(
Content.CallSignal(
Content.CallSignal.HANDOFF,
active.callId,
peer = active.peerId,
video = active.video,
),
)
}
}
/** Let an offer lapse. This is the withdrawal: a timeout, not an envelope. */
fun clearHandoffOffer() {
handoffLapse?.let { main.removeCallbacks(it) }
handoffLapse = null
if (handoffOffer != null) {
handoffOffer = null
main.post { runCatching { onHandoffOffer?.invoke(null) } }
}
}
/**
* Take a call another of this account's devices is on.
*
* The peer is sent an offer under the same call id, which it answers as a
* renegotiation without leaving its call screen. The device that had the
* call is told only once the offer is away, so a failure here leaves it
* still holding a working call.
*/
fun takeHandoff(turn: JSONObject?) {
val offer = handoffOffer ?: return
if (state != State.IDLE && state != State.ENDED) return
clearHandoffOffer()
val callId = offer.callId
current = Active(callId, offer.peerId, incoming = true, video = offer.video)
peerRenegotiates = true
state = State.CONNECTING
val pc =
open(turn, offer.peerId, callId, offer.video) ?: run {
current = null
state = State.IDLE
return
}
armConnectGuard()
pc.createOffer(
object : SimpleSdpObserver("handoff:create") {
override fun onCreateSuccess(sdp: SessionDescription) {
pc.setLocalDescription(SimpleSdpObserver("handoff:setLocal"), sdp)
main.post {
sendWhenGathered { full ->
signal(
offer.peerId,
Content.CallSignal(
Content.CallSignal.OFFER,
callId,
full,
video = offer.video,
renegotiates = true,
),
)
// Only now. The other device is still carrying the
// call until this line, which is what makes a
// failure above harmless.
runCatching {
tellOwnDevices?.invoke(
Content.CallSignal(
Content.CallSignal.HANDOFF_TAKEN,
callId,
),
)
}
}
}
}
},
MediaConstraints(),
)
}
/** Accept the call that is ringing. */
/**
* Build and send the answer.
*/
private fun answerNow(
pc: PeerConnection,
fromUser: Long,
offer: Content.CallSignal,
) {
pc.createAnswer(
object : SimpleSdpObserver("answer:create") {
override fun onCreateSuccess(answer: SessionDescription) {
pc.setLocalDescription(SimpleSdpObserver("answer:setLocal"), answer)
// Same as the offer: the answer is complete only after
// gathering, and one sent early names no address to reach.
main.post {
sendWhenGathered { full ->
signal(
fromUser,
Content.CallSignal(
Content.CallSignal.ANSWER,
offer.callId,
full,
video = offer.video,
renegotiates = true,
),
)
}
}
}
},
MediaConstraints(),
)
}
fun accept(turn: JSONObject?) {
if (CallPolicy.bridgeRefusal() != null) return
val (fromUser, offer) = pendingOffer ?: return
pendingOffer = null
tellOwnDevices?.invoke(
Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "answered-elsewhere"),
)
val sdp = offer.sdp ?: return
state = State.CONNECTING
armConnectGuard()
val pc =
open(turn, fromUser, offer.callId, offer.video) ?: run {
state = State.IDLE
return
}
current = Active(offer.callId, fromUser, incoming = true, video = offer.video)
// `createAnswer` waits for the remote description to be APPLIED.
pc.setRemoteDescription(
object : SimpleSdpObserver("answer:setRemote") {
override fun onSetSuccess() {
main.post { answerNow(pc, fromUser, offer) }
}
},
SessionDescription(SessionDescription.Type.OFFER, sdp),
)
}
/** Refuse a ringing call, and say so rather than letting it ring out. */
fun decline() {
val (fromUser, offer) = pendingOffer ?: return
pendingOffer = null
tellOwnDevices?.invoke(
Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "declined-elsewhere"),
)
signal(
fromUser,
Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "declined"),
)
// ENDED while `current` still names the caller, so the observer can
// file the declined call, see the ordering note in hangUpInner.
state = State.ENDED
current = null
state = State.IDLE
}
/**
* End the call and release everything.
*/
/**
* Re-entrancy-safe on purpose.
*/
private var tearingDown = false
fun hangUp(
notifyPeer: Boolean,
reason: String? = null,
) {
if (tearingDown) return
tearingDown = true
try {
hangUpInner(notifyPeer, reason)
} finally {
tearingDown = false
}
}
private fun hangUpInner(
notifyPeer: Boolean,
reason: String?,
) {
current?.callId?.let { noteEnded(it) }
onGathered = null
gatherGuard?.let { main.removeCallbacks(it) }
gatherGuard = null
dialGuard?.let { main.removeCallbacks(it) }
dialGuard = null
connectGuard?.let { main.removeCallbacks(it) }
connectGuard = null
cancelGoneGuard()
cancelRingGuard()
val active = current
if (notifyPeer && active != null) {
// Best effort.
runCatching {
signal(
active.peerId,
Content.CallSignal(Content.CallSignal.END, active.callId, reason = reason),
)
}
}
connection?.let {
runCatching { it.close() }
runCatching { it.dispose() }
}
connection = null
audioTrack?.let { runCatching { it.dispose() } }
audioTrack = null
MessengerService.callMediaEnded(context)
audioSource?.let { runCatching { it.dispose() } }
audioSource = null
// The camera, released in capture order.
videoCapturer?.let {
runCatching { it.stopCapture() }
runCatching { it.dispose() }
}
videoCapturer = null
videoHelper?.let { runCatching { it.dispose() } }
videoHelper = null
videoSource?.let { runCatching { it.dispose() } }
videoSource = null
localVideoTrack = null
remoteVideoTrack = null
main.post { runCatching { onVideoTracksChanged?.invoke() } }
pendingOffer = null
// ENDED fires while `current` still says who the call was with, and
// the observer files the call log entry from it. Null `current` first
// and no call, answered or missed, is ever recorded: by the time the
// state changes there is nobody to file.
state = State.ENDED
current = null
state = State.IDLE
}
fun setMuted(muted: Boolean) {
audioTrack?.setEnabled(!muted)
}
/** Release the factory. Called when the app is done, not between calls. */
fun shutdown() {
hangUp(notifyPeer = false)
factory?.dispose()
factory = null
}
companion object {
/**
* The call manager this process is using, if there is one.
*
* Two loops poll the same mailbox: the foreground one the view model
* drives, and the service's own, which keeps the mailbox watched while
* nothing is on screen. Whichever fetches an envelope first consumes
* it -- the relay marks it delivered and it is not handed out again.
* The service's handler knew what to do with an offer and an end and
* discarded everything else, so an ANSWER fetched by the service was
* simply thrown away. The caller then sat on "Calling..." for the full
* forty-five seconds and gave up, every time the race went that way.
*
* Registering here lets either loop hand a signal to the call that is
* actually up, instead of one of them holding the only route.
*/
@Volatile
@JvmStatic
var live: CallManager? = null
/**
* Whether a call is being set up or is up right now.
*
* The poll loop asks this to decide how long it may let the relay hold
* a request open. Ordinarily twenty seconds is right and cheap. During
* a call it is the difference between an answer landing when it is sent
* and landing twenty seconds later, by which time the caller has
* watched "Calling..." for long enough to assume it is broken -- which
* is exactly what was measured: an offer at :07.9 and its answer
* accepted at :28.5.
*/
@JvmStatic
fun aCallIsUp(): Boolean =
when (live?.state) {
State.DIALLING, State.RINGING, State.CONNECTING, State.ACTIVE -> true
else -> false
}
/** How long an unanswered outgoing call rings before giving up. */
const val DIAL_TIMEOUT_MS = 45_000L
/**
* A little past the caller's own patience, so this end releases a ring
* only when their hang-up never reached us.
*/
const val RING_TIMEOUT_MS = 60_000L
/**
* How long a connected call may stay disconnected before it is over.
* Long enough for a real blip, short enough not to be a stuck screen.
*/
const val GONE_TIMEOUT_MS = 8_000L
const val CONNECT_TIMEOUT_MS = 30_000L
/**
* How long to wait for ICE gathering before sending what we have.
*/
const val GATHER_TIMEOUT_MS = 8_000L
}
}
/**
* The offer the background service received, parked for the UI to answer.
*/
object PendingIncomingCall {
data class Parked(
val fromUser: Long,
val signal: Content.CallSignal,
val at: Long,
)
@Volatile private var parked: Parked? = null
fun park(
fromUser: Long,
signal: Content.CallSignal,
sentAt: Long,
) {
parked =
Parked(
fromUser,
signal,
if (sentAt > 0) sentAt else System.currentTimeMillis(),
)
}
/** The caller hung up or the call was answered elsewhere, stop offering it. */
fun clear(callId: String) {
if (parked?.signal?.callId == callId) parked = null
}
/**
* Drop whatever is parked, whichever call it is.
*/
fun clearAll() {
parked = null
}
/**
* Whether something is parked that is already too old to answer.
*/
fun isStale(now: Long = System.currentTimeMillis()): Boolean {
val current = parked ?: return false
return now - current.at > CallManager.DIAL_TIMEOUT_MS
}
/** Hand the offer over exactly once, and never one old enough to be dead. */
fun take(): Parked? {
val current = parked ?: return null
parked = null
val age = System.currentTimeMillis() - current.at
return if (age > CallManager.DIAL_TIMEOUT_MS) null else current
}
}
/** Most SDP callbacks have nothing to say; this stops four stubs per call site. */
/**
* This discarded every SDP failure, and that is why calls were undebuggable.
*/
private open class SimpleSdpObserver(
private val label: String = "sdp",
) : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription) = Unit
override fun onSetSuccess() = Unit
override fun onCreateFailure(error: String?) {
android.util.Log.w("fnmsg-call", "$label: createOffer/createAnswer FAILED: $error")
}
override fun onSetFailure(error: String?) {
android.util.Log.w("fnmsg-call", "$label: setLocal/setRemoteDescription FAILED: $error")
}
}