diff --git a/PARITY.md b/PARITY.md
index 10e365c..38111c8 100644
--- a/PARITY.md
+++ b/PARITY.md
@@ -109,12 +109,34 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
| Remove, block, unblock | ✅ |
| Member lookup | ✅ look up before adding; adding is gated on it |
| Buddy commands | — retired; not a feature any more (owner, 2026-08-25) |
-| Voice calls (WebRTC, DTLS-SRTP, TURN) | ⚪ |
+| Voice calls (WebRTC, DTLS-SRTP, TURN) | ✅ P2P-first; ⚠️ no TURN deployed |
| Notifications list, read state | ✅ |
| Services (AIM/ICQ via `flamenet-oscar`) | ⚪ |
-⚠️ Calls need a WebRTC dependency on Android as they do on iOS. That is the one
-place parity forces a third-party library rather than a choice about one.
+✅ Calls are built, on `io.github.webrtc-sdk:android` — the one third-party
+library in this app, and unavoidable: the alternative is hand-rolling ICE,
+DTLS-SRTP and an audio pipeline.
+
+🔴 **Peer to peer by default.** ICE tries a direct path first and only falls
+back to TURN when both ends are behind NAT that refuses one. Signalling rides
+the ratchet, so the SDP — codecs and every candidate address — is end-to-end
+encrypted; a relay that could read it 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. The app says so before the FIRST call and waits for an answer —
+once, not every time, because a prompt people see repeatedly is one they stop
+reading.
+
+⚠️ **No TURN server is deployed** (`FN_TURN_SECRET` / `FN_TURN_HOST` unset), so
+`/e2e/turn` answers 503 and calls are P2P-or-fail. Handled rather than hidden:
+STUN alone still connects through most NATs, and the call banner says plainly
+that this server has no relay so a call only connects if a direct path exists.
+
+⚠️ **WebRTC's native libraries dominate the APK** — ~41 MB across four ABIs, of
+which x86/x86_64 (~24 MB) exist only for emulators. Release is restricted to
+arm64-v8a and armeabi-v7a; debug keeps all four so it still runs on an
+emulator.
---
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e4a0386..da6c621 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -62,6 +62,17 @@ android {
buildTypes {
release {
+ // ⚠️ **WebRTC ships native libraries for four ABIs and they
+ // dominate the APK** — about 41 MB uncompressed, of which x86 and
+ // x86_64 are ~24 MB and exist only for emulators. Real phones are
+ // arm64-v8a, with armeabi-v7a for old ones.
+ //
+ // Restricted here rather than split into per-ABI APKs 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".
+ //
+ // Debug keeps all four, because that is what runs on an emulator.
+ ndk { abiFilters += setOf("arm64-v8a", "armeabi-v7a") }
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.findByName("release")
@@ -88,6 +99,21 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+ // Voice calls (§8.10).
+ //
+ // The one third-party dependency in this app, and it is here because the
+ // alternative is implementing ICE, DTLS-SRTP and an audio pipeline, which
+ // is not a thing to hand-roll. `webrtc-sdk` is the maintained build of
+ // Google's own libwebrtc — the upstream project stopped publishing usable
+ // Android artifacts, and every Android client of consequence uses either
+ // this or its own build of the same source.
+ //
+ // ⚠️ It is a large native blob: roughly 15-20 MB of the APK, and not
+ // something that can be audited line by line. Worth stating rather than
+ // absorbing quietly, because everything else in this app deliberately is
+ // auditable.
+ implementation("io.github.webrtc-sdk:android:125.6422.07")
+
// On-device tests. The JVM suites cannot see a provider difference, and a
// provider difference is what broke X25519 the first time this engine's
// sibling ran on a phone.
diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt
index 0bdec60..1da288a 100644
--- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt
+++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt
@@ -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()
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f73203e..3d0de58 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,6 +12,9 @@
+
+
+
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 {
+ 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?) = 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?) = 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? = 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
+}
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt
index c69125a..a241f43 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt
@@ -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) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt
index 006364e..d56bb03 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt
@@ -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)
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt
index 1a55ec4..63c104b 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt
@@ -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().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
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt
index 5fd58a6..2a75bf8 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt
@@ -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 {
- 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()
val now = System.currentTimeMillis() / 1000.0
val incomingStories = mutableListOf()
diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt
index f5e2e8d..a7ce2c3 100644
--- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt
+++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt
@@ -236,6 +236,17 @@ class Conversations(
data class StoryPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int)
+ /**
+ * Send one call signal (§8.10).
+ *
+ * ⚠️ Down the ratchet like any message, so the SDP — which names codecs
+ * and every candidate address — is end-to-end encrypted. A relay that
+ * could read signalling would learn both parties' addresses even on a call
+ * it never carries.
+ */
+ fun sendCallSignal(toUserId: Long, signal: Content.CallSignal): Sent =
+ sendPlaintext(toUserId, Content.callSignal(signal))
+
/** Download and open a blob a message referred to. */
fun fetchAttachment(ref: Attachment.Ref): ByteArray =
Attachment.open(relay.downloadAttachment(ref.id), ref)