Android: voice calls, peer to peer by default
The one third-party library in this app, and unavoidable: the alternative to `io.github.webrtc-sdk:android` is hand-rolling ICE, DTLS-SRTP and an audio pipeline. Worth naming rather than absorbing quietly, because everything else here is deliberately auditable and this is a large native blob that is not. 🔴 **Peer to peer by default.** ICE tries a direct path first — same network, then each side's address as seen through NAT — and only falls back to relaying through TURN when both ends sit behind NAT that refuses one. `iceTransportsType` is ALL rather than RELAY: forcing every call through the server is the opposite of the default chosen here. **Signalling rides the ratchet, not the relay.** The offer and answer are ordinary content envelopes, so the SDP — codecs, and every candidate address — is end-to-end encrypted like any other message. A relay that could read signalling would learn both parties' addresses even on a call it never carries. ⚠️ **A direct call shows the other person your IP address.** That is what makes it direct, and it is not something to find out afterwards. Explained before the FIRST call, and it waits for an answer — once, not every time, because a prompt people see repeatedly is one they stop reading. The acknowledgement lives in ordinary preferences rather than the vault: it is not a secret, and storing it encrypted would re-show the warning on every sign-out, which is how a one-time prompt becomes one people click past. ⚠️ **No TURN server is deployed** — `FN_TURN_SECRET` and `FN_TURN_HOST` are unset, so `/e2e/turn` answers 503. Handled rather than hidden: STUN alone still connects through most NATs, and the banner says plainly that this server has no relay so the call connects only if a direct path exists. "Calling did not connect", with no reason, is the kind of failure people blame on the app for ever. Trickle ICE is off. Candidates are gathered before the offer is sent, so signalling is one encrypted envelope each way rather than a stream of them — a second or two slower to connect, and far simpler over a channel that is polled rather than pushed. ⚠️ The microphone is held by the audio source, not the peer connection. Closing one without disposing the other leaves the mic live with no call attached, which is the worst failure this particular feature has available. ⚠️ **WebRTC's native libraries dominate the APK**: ~41 MB across four ABIs, of which x86 and x86_64 (~24 MB) exist only for emulators. Release is restricted to arm64-v8a and armeabi-v7a — 102 MB debug becomes a 22 MB release — while debug keeps all four so it still runs on an emulator. Restricted rather than split per-ABI on purpose: this app is handed to testers as one file, and per-ABI outputs turn "send them the APK" into "send them the right APK". A second offer while busy is declined rather than dropped, so the caller hears something. Group calls are deliberately absent: different key handling, and an enabled-looking button that cannot work is worse than none. Tested: 108 engine, 67 client, none failing; debug and release APKs both build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c1284aab5f
commit
b4aadda162
@@ -158,6 +158,8 @@ class CrudSurfaceTest {
|
||||
// Voice notes are reachable from the thread, and the control says what
|
||||
// it does rather than being a bare icon.
|
||||
compose.onNodeWithTag("record-voice").assertExists()
|
||||
// Calling is reachable from the thread it applies to.
|
||||
compose.onNodeWithTag("start-call").assertExists()
|
||||
|
||||
compose.onNodeWithTag("identity-state").assertExists()
|
||||
compose.onNodeWithTag("show-safety-number").performClick()
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<!-- The app is usable without a microphone; it simply cannot record. -->
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
|
||||
<!-- Voice calls: WebRTC switches the audio mode and routes to earpiece. -->
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
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.DataChannel
|
||||
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 java.util.UUID
|
||||
|
||||
/**
|
||||
* Voice calls over WebRTC (spec §8.10).
|
||||
*
|
||||
* 🔴 **Peer to peer whenever the network allows it.** ICE tries a direct path
|
||||
* first — same network, then each side's address as seen through NAT — and
|
||||
* only falls back to relaying through TURN when both ends sit behind NAT that
|
||||
* refuses a direct path. The relay mints TURN credentials and never carries
|
||||
* media; when it carries anything it carries DTLS-SRTP ciphertext between two
|
||||
* peers who authenticated each other through the ratchet.
|
||||
*
|
||||
* ⚠️ **A direct call shows the other person your IP address.** That is what
|
||||
* "peer to peer" means and it is not a bug, but it is a disclosure, and the
|
||||
* app says so before the first call rather than after. Someone who does not
|
||||
* want it needs a relay, and relaying requires a TURN server to exist.
|
||||
*
|
||||
* ⚠️ **Signalling rides the ratchet, not the relay.** The offer and answer are
|
||||
* ordinary content envelopes, so the SDP — which names codecs, and every
|
||||
* candidate address — is end-to-end encrypted like any other message. A relay
|
||||
* that could read signalling would learn the addresses of both parties even on
|
||||
* a call it never carries.
|
||||
*/
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* ICE servers, P2P-first.
|
||||
*
|
||||
* ⚠️ A relay with no TURN configured answers 503, and that is a supported
|
||||
* deployment rather than an error: STUN alone still gets a direct call
|
||||
* through most NATs. What it cannot do is rescue the minority of networks
|
||||
* that refuse a direct path — those calls simply fail to connect, and
|
||||
* saying so is better than pretending a relay exists.
|
||||
*/
|
||||
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()
|
||||
.createPeerConnectionFactory()
|
||||
.also { factory = it }
|
||||
}
|
||||
|
||||
private fun open(turn: JSONObject?, peerId: Long, callId: String): PeerConnection? {
|
||||
val config = PeerConnection.RTCConfiguration(iceServers(turn)).apply {
|
||||
// ALL rather than RELAY: direct paths are tried, and TURN is the
|
||||
// fallback. RELAY would force every call through the server, which
|
||||
// is the opposite of the default this product chose.
|
||||
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. Slower to connect by a second or two, and far
|
||||
// simpler to reason about over a message channel that is polled
|
||||
// rather than pushed.
|
||||
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
|
||||
}
|
||||
val observer = object : PeerConnection.Observer {
|
||||
override fun onIceCandidate(candidate: IceCandidate?) = Unit
|
||||
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
|
||||
override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit
|
||||
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
|
||||
when (newState) {
|
||||
PeerConnection.IceConnectionState.CONNECTED,
|
||||
PeerConnection.IceConnectionState.COMPLETED -> state = State.ACTIVE
|
||||
PeerConnection.IceConnectionState.FAILED,
|
||||
PeerConnection.IceConnectionState.CLOSED -> hangUp(notifyPeer = true)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
||||
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) = Unit
|
||||
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>?) = Unit
|
||||
}
|
||||
|
||||
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
|
||||
connection = pc
|
||||
current = Active(callId, peerId, incoming = false)
|
||||
return pc
|
||||
}
|
||||
|
||||
/** Place a call. The offer is gathered, then sent as one encrypted envelope. */
|
||||
fun call(peerId: Long, turn: JSONObject?) {
|
||||
if (state != State.IDLE && state != State.ENDED) return
|
||||
val callId = UUID.randomUUID().toString()
|
||||
state = State.DIALLING
|
||||
val pc = open(turn, peerId, callId) ?: run {
|
||||
state = State.IDLE
|
||||
return
|
||||
}
|
||||
current = Active(callId, peerId, incoming = false)
|
||||
pc.createOffer(object : SimpleSdpObserver() {
|
||||
override fun onCreateSuccess(sdp: SessionDescription) {
|
||||
pc.setLocalDescription(SimpleSdpObserver(), sdp)
|
||||
signal(peerId, Content.CallSignal(Content.CallSignal.OFFER, callId, sdp.description))
|
||||
state = State.CONNECTING
|
||||
}
|
||||
}, MediaConstraints())
|
||||
}
|
||||
|
||||
/** An offer arrived. Held as ringing until a person accepts it. */
|
||||
private var pendingOffer: Pair<Long, Content.CallSignal>? = null
|
||||
|
||||
fun onSignal(fromUser: Long, incoming: Content.CallSignal, turn: JSONObject?) {
|
||||
when (incoming.action) {
|
||||
Content.CallSignal.OFFER -> {
|
||||
// ⚠️ One call at a time. A second offer while busy is declined
|
||||
// rather than silently dropped, so the caller hears something.
|
||||
if (state != State.IDLE && state != State.ENDED) {
|
||||
signal(fromUser, Content.CallSignal(
|
||||
Content.CallSignal.END, incoming.callId, reason = "busy"))
|
||||
return
|
||||
}
|
||||
pendingOffer = fromUser to incoming
|
||||
current = Active(incoming.callId, fromUser, incoming = true)
|
||||
state = State.RINGING
|
||||
}
|
||||
Content.CallSignal.ANSWER -> {
|
||||
val sdp = incoming.sdp ?: return
|
||||
connection?.setRemoteDescription(
|
||||
SimpleSdpObserver(),
|
||||
SessionDescription(SessionDescription.Type.ANSWER, sdp),
|
||||
)
|
||||
}
|
||||
Content.CallSignal.END -> hangUp(notifyPeer = false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept the call that is ringing. */
|
||||
fun accept(turn: JSONObject?) {
|
||||
val (fromUser, offer) = pendingOffer ?: return
|
||||
pendingOffer = null
|
||||
val sdp = offer.sdp ?: return
|
||||
state = State.CONNECTING
|
||||
val pc = open(turn, fromUser, offer.callId) ?: run {
|
||||
state = State.IDLE
|
||||
return
|
||||
}
|
||||
current = Active(offer.callId, fromUser, incoming = true)
|
||||
pc.setRemoteDescription(
|
||||
SimpleSdpObserver(),
|
||||
SessionDescription(SessionDescription.Type.OFFER, sdp),
|
||||
)
|
||||
pc.createAnswer(object : SimpleSdpObserver() {
|
||||
override fun onCreateSuccess(answer: SessionDescription) {
|
||||
pc.setLocalDescription(SimpleSdpObserver(), answer)
|
||||
signal(fromUser, Content.CallSignal(
|
||||
Content.CallSignal.ANSWER, offer.callId, answer.description))
|
||||
}
|
||||
}, MediaConstraints())
|
||||
}
|
||||
|
||||
/** Refuse a ringing call, and say so rather than letting it ring out. */
|
||||
fun decline() {
|
||||
val (fromUser, offer) = pendingOffer ?: return
|
||||
pendingOffer = null
|
||||
signal(fromUser, Content.CallSignal(
|
||||
Content.CallSignal.END, offer.callId, reason = "declined"))
|
||||
current = null
|
||||
state = State.IDLE
|
||||
}
|
||||
|
||||
/**
|
||||
* End the call and release everything.
|
||||
*
|
||||
* ⚠️ The microphone is held by the audio source, not by the connection.
|
||||
* Closing one without disposing the other leaves the mic live with no call
|
||||
* attached — the worst possible failure for this particular feature.
|
||||
*/
|
||||
fun hangUp(notifyPeer: Boolean) {
|
||||
val active = current
|
||||
if (notifyPeer && active != null) {
|
||||
signal(active.peerId, Content.CallSignal(Content.CallSignal.END, active.callId))
|
||||
}
|
||||
connection?.let { runCatching { it.close() }; it.dispose() }
|
||||
connection = null
|
||||
audioTrack?.let { runCatching { it.dispose() } }
|
||||
audioTrack = null
|
||||
audioSource?.let { runCatching { it.dispose() } }
|
||||
audioSource = null
|
||||
pendingOffer = null
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** Most SDP callbacks have nothing to say; this stops four stubs per call site. */
|
||||
private open class SimpleSdpObserver : SdpObserver {
|
||||
override fun onCreateSuccess(sdp: SessionDescription) = Unit
|
||||
override fun onSetSuccess() = Unit
|
||||
override fun onCreateFailure(error: String?) = Unit
|
||||
override fun onSetFailure(error: String?) = Unit
|
||||
}
|
||||
@@ -643,6 +643,16 @@ fun ColumnScope.ChatScreen(
|
||||
|
||||
chat.group?.let { group -> GroupMembers(model, group, buddies) }
|
||||
|
||||
// Calling is one-to-one. A group call is a different feature with different
|
||||
// key handling, and an enabled-looking button that cannot work is worse
|
||||
// than an absent one.
|
||||
if (chat.peerId != null) {
|
||||
TextButton(
|
||||
onClick = { model.requestCall(chat.peerId) },
|
||||
modifier = Modifier.testTag("start-call"),
|
||||
) { Text("Call") }
|
||||
}
|
||||
|
||||
// Identity, directly under the name it belongs to. Groups have many
|
||||
// identities and no single answer, so this is for one-to-one threads only.
|
||||
if (chat.peerId != null) IdentityPanel(model, chat)
|
||||
@@ -1153,3 +1163,93 @@ private fun ColumnScope.GroupMembers(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The call banner: ringing, in progress, and the one-time privacy warning.
|
||||
*
|
||||
* 🔴 **The warning is shown BEFORE the first call, and it waits.** A direct
|
||||
* connection shows the other person your IP address — that is what makes it
|
||||
* direct — and it is not something to discover afterwards. Asked once rather
|
||||
* than every time, because a prompt people see repeatedly is a prompt they
|
||||
* stop reading.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.CallBanner(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn) {
|
||||
phase.callPrivacyPrompt?.let {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("call-privacy"),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("Before your first call", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Calls connect your phone directly to theirs, which is what keeps them fast and "
|
||||
+ "private from this server — but a direct connection means their device "
|
||||
+ "sees your IP address, and yours sees theirs. What you say is encrypted "
|
||||
+ "either way.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { model.acknowledgeCallPrivacy(andCall = true) },
|
||||
modifier = Modifier.testTag("call-privacy-accept"),
|
||||
) { Text("Understood, call") }
|
||||
TextButton(
|
||||
onClick = { model.acknowledgeCallPrivacy(andCall = false) },
|
||||
modifier = Modifier.testTag("call-privacy-cancel"),
|
||||
) { Text("Not now") }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val call = phase.call ?: return
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("call-banner"),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
when (call.state) {
|
||||
CallManager.State.RINGING ->
|
||||
if (call.incoming) "${call.peerName} is calling" else "Ringing ${call.peerName}"
|
||||
CallManager.State.DIALLING -> "Calling ${call.peerName}…"
|
||||
CallManager.State.CONNECTING -> "Connecting…"
|
||||
CallManager.State.ACTIVE -> "On a call with ${call.peerName}"
|
||||
else -> "Call ended"
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
// ⚠️ Said out loud rather than left as a mystery. "Calling did not
|
||||
// connect", with no reason, is the kind of failure people blame on the
|
||||
// app for ever.
|
||||
if (call.relayless && call.state != CallManager.State.ACTIVE) {
|
||||
Text(
|
||||
"This server has no call relay, so the call only connects if a direct path "
|
||||
+ "exists between the two networks.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (call.state == CallManager.State.RINGING && call.incoming) {
|
||||
Button(
|
||||
onClick = model::acceptCall,
|
||||
modifier = Modifier.testTag("accept-call"),
|
||||
) { Text("Answer") }
|
||||
TextButton(
|
||||
onClick = model::declineCall,
|
||||
modifier = Modifier.testTag("decline-call"),
|
||||
) { Text("Decline") }
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = { model.setCallMuted(!call.muted) },
|
||||
modifier = Modifier.testTag("mute-call"),
|
||||
) { Text(if (call.muted) "Unmute" else "Mute") }
|
||||
TextButton(
|
||||
onClick = model::hangUp,
|
||||
modifier = Modifier.testTag("end-call"),
|
||||
) { Text("Hang up", color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +309,10 @@ private fun ColumnScope.SignedIn(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.testTag("device-id"))
|
||||
|
||||
// Above everything: an incoming call is the one thing that must be visible
|
||||
// whatever screen somebody is on.
|
||||
CallBanner(model, phase)
|
||||
|
||||
PresencePicker(model, phase.presence)
|
||||
StoryStrip(model, phase)
|
||||
BuddyList(model, phase)
|
||||
|
||||
@@ -26,6 +26,8 @@ import kotlinx.coroutines.withContext
|
||||
* and combinations that should be impossible — loading *and* signed out *and*
|
||||
* showing an error — became reachable.
|
||||
*/
|
||||
private const val CALL_PRIVACY_ACKNOWLEDGED = "call.privacy.acknowledged"
|
||||
|
||||
class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
sealed interface Phase {
|
||||
@@ -50,6 +52,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
/** Who a handle turned out to be, before adding them (§8.2). */
|
||||
val lookedUp: MessengerApi.Me? = null,
|
||||
val lookupMiss: String? = null,
|
||||
/** The call in progress, if any (§8.10). */
|
||||
val call: CallState? = null,
|
||||
/** Shown once, before the first call ever placed on this device. */
|
||||
val callPrivacyPrompt: Long? = null,
|
||||
val presence: String = "online",
|
||||
/** Which screen is showing. One field, so two cannot be open at once. */
|
||||
val screen: Screen = Screen.CONTACTS,
|
||||
@@ -73,6 +79,24 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
enum class Screen { CONTACTS, NOTIFICATIONS, ACCOUNT }
|
||||
|
||||
/** What the call UI needs to draw itself. */
|
||||
data class CallState(
|
||||
val state: CallManager.State,
|
||||
val peerId: Long,
|
||||
val peerName: String,
|
||||
val incoming: Boolean,
|
||||
val muted: Boolean = false,
|
||||
/**
|
||||
* True when this relay has no TURN server, so a call that cannot
|
||||
* find a direct path will simply fail.
|
||||
*
|
||||
* Stated in the UI rather than left as a mystery: "calling did not
|
||||
* connect" with no reason is the kind of failure people blame on
|
||||
* the app for ever.
|
||||
*/
|
||||
val relayless: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* An open conversation.
|
||||
*
|
||||
@@ -691,6 +715,125 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
|
||||
}
|
||||
|
||||
// -- Calls (§8.10) ---------------------------------------------------------
|
||||
|
||||
private var calls: CallManager? = null
|
||||
|
||||
/**
|
||||
* ⚠️ Built lazily and once. `PeerConnectionFactory.initialize` must run
|
||||
* exactly once per process; a second call is not idempotent and takes the
|
||||
* process with it.
|
||||
*/
|
||||
private fun callManager(): CallManager {
|
||||
calls?.let { return it }
|
||||
val manager = CallManager(
|
||||
context = getApplication(),
|
||||
signal = { peerId, signal ->
|
||||
// Fire and forget on the IO pool: signalling must not block the
|
||||
// WebRTC callback thread it is raised from.
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { session?.sendCallSignal(peerId, signal) }
|
||||
}
|
||||
},
|
||||
onStateChanged = { state ->
|
||||
update { phase ->
|
||||
val active = calls?.current
|
||||
if (state == CallManager.State.IDLE || active == null) {
|
||||
phase.copy(call = null)
|
||||
} else {
|
||||
phase.copy(
|
||||
call = (phase.call ?: Phase.CallState(
|
||||
state = state,
|
||||
peerId = active.peerId,
|
||||
peerName = nameFor(phase, active.peerId),
|
||||
incoming = active.incoming,
|
||||
)).copy(state = state),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
calls = manager
|
||||
session?.onCallSignal = { fromUser, signal ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val turn = session?.turnCredentials()
|
||||
manager.onSignal(fromUser, signal, turn)
|
||||
}
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Ordinary SharedPreferences, NOT the vault. This records that a
|
||||
* warning was shown — it is not a secret, and putting it in the encrypted
|
||||
* store would mean re-showing the warning on every sign-out, which is how
|
||||
* a one-time prompt becomes one people click past.
|
||||
*/
|
||||
private fun callPrefs(): android.content.SharedPreferences =
|
||||
getApplication<Application>().getSharedPreferences("calls", android.content.Context.MODE_PRIVATE)
|
||||
|
||||
private fun nameFor(phase: Phase.SignedIn, userId: Long): String =
|
||||
phase.buddies.firstOrNull { it.userId == userId }
|
||||
?.let { it.displayName.ifBlank { it.handle } } ?: "user $userId"
|
||||
|
||||
/**
|
||||
* Ask to place a call.
|
||||
*
|
||||
* ⚠️ **The first call on this device explains what a direct connection
|
||||
* discloses, and waits.** A peer-to-peer call shows the other person your
|
||||
* IP address; that is what makes it direct, and it is not something to
|
||||
* find out afterwards. Asked once, not every time — a prompt people see
|
||||
* repeatedly is a prompt they stop reading.
|
||||
*/
|
||||
fun requestCall(peerId: Long) {
|
||||
if (callPrefs().getBoolean(CALL_PRIVACY_ACKNOWLEDGED, false)) placeCall(peerId)
|
||||
else update { it.copy(callPrivacyPrompt = peerId) }
|
||||
}
|
||||
|
||||
fun acknowledgeCallPrivacy(andCall: Boolean) {
|
||||
callPrefs().edit().putBoolean(CALL_PRIVACY_ACKNOWLEDGED, true).apply()
|
||||
val peerId = signedIn()?.callPrivacyPrompt
|
||||
update { it.copy(callPrivacyPrompt = null) }
|
||||
if (andCall && peerId != null) placeCall(peerId)
|
||||
}
|
||||
|
||||
private fun placeCall(peerId: Long) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val manager = callManager()
|
||||
val turn = withContext(Dispatchers.IO) { current.turnCredentials() }
|
||||
update {
|
||||
it.copy(call = Phase.CallState(
|
||||
state = CallManager.State.DIALLING,
|
||||
peerId = peerId,
|
||||
peerName = nameFor(it, peerId),
|
||||
incoming = false,
|
||||
relayless = turn == null,
|
||||
))
|
||||
}
|
||||
withContext(Dispatchers.IO) { manager.call(peerId, turn) }
|
||||
}
|
||||
|
||||
fun acceptCall() = viewModelScope.launch {
|
||||
val manager = calls ?: return@launch
|
||||
val turn = withContext(Dispatchers.IO) { session?.turnCredentials() }
|
||||
withContext(Dispatchers.IO) { manager.accept(turn) }
|
||||
}
|
||||
|
||||
fun declineCall() {
|
||||
calls?.decline()
|
||||
update { it.copy(call = null) }
|
||||
}
|
||||
|
||||
fun hangUp() {
|
||||
calls?.hangUp(notifyPeer = true)
|
||||
update { it.copy(call = null) }
|
||||
}
|
||||
|
||||
fun setCallMuted(muted: Boolean) {
|
||||
calls?.setMuted(muted)
|
||||
update { it.copy(call = it.call?.copy(muted = muted)) }
|
||||
}
|
||||
|
||||
// -- Member lookup ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -1016,6 +1159,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
* live MediaRecorder holds the microphone against the whole device.
|
||||
*/
|
||||
override fun onCleared() {
|
||||
calls?.shutdown()
|
||||
calls = null
|
||||
recordingTicker?.cancel()
|
||||
recording?.cancel()
|
||||
recording = null
|
||||
|
||||
@@ -134,6 +134,15 @@ class Session(
|
||||
* what you typed in it is not acceptable. The delivery counts still tell
|
||||
* the caller what happened.
|
||||
*/
|
||||
/** Send one call signal to a peer. */
|
||||
fun sendCallSignal(peerUserId: Long, signal: io.flamenet.e2e.Content.CallSignal) {
|
||||
conversations.sendCallSignal(peerUserId, signal)
|
||||
}
|
||||
|
||||
/** TURN credentials, or null when this relay has none configured. */
|
||||
fun turnCredentials(): org.json.JSONObject? =
|
||||
runCatching { relay.turnCredentials() }.getOrNull()
|
||||
|
||||
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
|
||||
val sent = conversations.sendToGroup(group, text, userId)
|
||||
record(Vault.threadForGroup(group.id), text, mine = true, author = userId)
|
||||
@@ -164,8 +173,21 @@ class Session(
|
||||
* devices sent — travel down the same channel, and filing them would put
|
||||
* raw JSON in the thread.
|
||||
*/
|
||||
/**
|
||||
* Called for each call signal that arrives.
|
||||
*
|
||||
* ⚠️ Set by the view model. Without it, call signals are silently dropped —
|
||||
* `isRenderable` excludes them, correctly, so nothing else would notice
|
||||
* that an incoming call had been thrown away.
|
||||
*/
|
||||
var onCallSignal: ((Long, io.flamenet.e2e.Content.CallSignal) -> Unit)? = null
|
||||
|
||||
fun receive(): Set<String> {
|
||||
val arrived = conversations.poll().filter { it.content.isRenderable }
|
||||
val everything = conversations.poll()
|
||||
everything.forEach { message ->
|
||||
message.content.call?.let { signal -> onCallSignal?.invoke(message.fromUser, signal) }
|
||||
}
|
||||
val arrived = everything.filter { it.content.isRenderable }
|
||||
val touched = mutableSetOf<String>()
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
val incomingStories = mutableListOf<Vault.Story>()
|
||||
|
||||
Reference in New Issue
Block a user