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)