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:
Paul Hitt
2026-08-25 15:13:54 -04:00
co-authored by Claude Opus 5
parent c1284aab5f
commit b4aadda162
10 changed files with 611 additions and 4 deletions
+25 -3
View File
@@ -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.
---
+26
View File
@@ -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.
@@ -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()
+3
View File
@@ -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>()
@@ -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)