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>
1242 lines
48 KiB
Kotlin
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")
|
|
}
|
|
}
|