feat: RoomUpdate protocol — broadcast participant list on join/leave

- Add RoomUpdate signal message to wzp-proto with participant count + list
- Add RoomParticipant struct (fingerprint + optional alias)
- Store fingerprint/alias in relay Participant struct
- Broadcast RoomUpdate to all room members on join and leave
- Add signal recv task in Android engine to handle RoomUpdate
- Surface room_participant_count + room_participants in CallStats JSON
- Show "X in room" with participant names in Android in-call UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-05 18:12:24 +00:00
parent a23d9f5e41
commit 2d4b8eebd5
9 changed files with 187 additions and 16 deletions

View File

@@ -1,5 +1,6 @@
package com.wzp.engine package com.wzp.engine
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
/** /**
@@ -32,6 +33,10 @@ data class CallStats(
val fecRecovered: Long = 0, val fecRecovered: Long = 0,
/** Current mic audio level (RMS, 0-32767). */ /** Current mic audio level (RMS, 0-32767). */
val audioLevel: Int = 0, val audioLevel: Int = 0,
/** Number of participants in the room. */
val roomParticipantCount: Int = 0,
/** Participants in the room (fingerprint + optional alias). */
val roomParticipants: List<RoomMember> = emptyList(),
) { ) {
/** Human-readable quality label. */ /** Human-readable quality label. */
val qualityLabel: String val qualityLabel: String
@@ -43,6 +48,17 @@ data class CallStats(
} }
companion object { companion object {
private fun parseParticipants(arr: JSONArray?): List<RoomMember> {
if (arr == null) return emptyList()
return (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
RoomMember(
fingerprint = o.optString("fingerprint", ""),
alias = o.optString("alias", null)
)
}
}
/** Deserialise from the JSON string produced by the native engine. */ /** Deserialise from the JSON string produced by the native engine. */
fun fromJson(json: String): CallStats { fun fromJson(json: String): CallStats {
return try { return try {
@@ -59,7 +75,9 @@ data class CallStats(
framesDecoded = obj.optLong("frames_decoded", 0), framesDecoded = obj.optLong("frames_decoded", 0),
underruns = obj.optLong("underruns", 0), underruns = obj.optLong("underruns", 0),
fecRecovered = obj.optLong("fec_recovered", 0), fecRecovered = obj.optLong("fec_recovered", 0),
audioLevel = obj.optInt("audio_level", 0) audioLevel = obj.optInt("audio_level", 0),
roomParticipantCount = obj.optInt("room_participant_count", 0),
roomParticipants = parseParticipants(obj.optJSONArray("room_participants"))
) )
} catch (e: Exception) { } catch (e: Exception) {
CallStats() CallStats()
@@ -67,3 +85,12 @@ data class CallStats(
} }
} }
} }
data class RoomMember(
val fingerprint: String,
val alias: String? = null
) {
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
val displayName: String
get() = alias ?: fingerprint.take(8)
}

View File

@@ -228,6 +228,22 @@ fun InCallScreen(
QualityIndicator(qualityTier, stats.qualityLabel) QualityIndicator(qualityTier, stats.qualityLabel)
if (stats.roomParticipantCount > 0) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${stats.roomParticipantCount} in room",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
stats.roomParticipants.forEach { member ->
Text(
text = member.displayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
AudioLevelBar(stats.audioLevel) AudioLevelBar(stats.audioLevel)

View File

@@ -529,10 +529,45 @@ async fn run_call(
} }
}; };
// Signal recv task — listens for RoomUpdate and other signaling messages
let transport_signal = transport.clone();
let state_signal = state.clone();
let signal_task = async {
loop {
match transport_signal.recv_signal().await {
Ok(Some(SignalMessage::RoomUpdate { count, participants })) => {
info!(count, "RoomUpdate received");
let members: Vec<crate::stats::RoomMember> = participants
.iter()
.map(|p| crate::stats::RoomMember {
fingerprint: p.fingerprint.clone(),
alias: p.alias.clone(),
})
.collect();
let mut stats = state_signal.stats.lock().unwrap();
stats.room_participant_count = count;
stats.room_participants = members;
}
Ok(Some(msg)) => {
info!("signal received: {:?}", std::mem::discriminant(&msg));
}
Ok(None) => {
info!("signal stream closed");
break;
}
Err(e) => {
warn!("signal recv error: {e}");
break;
}
}
}
};
tokio::select! { tokio::select! {
_ = send_task => {} _ = send_task => {}
_ = recv_task => {} _ = recv_task => {}
_ = stats_task => {} _ = stats_task => {}
_ = signal_task => {}
} }
transport.close().await.ok(); transport.close().await.ok();

View File

@@ -53,4 +53,15 @@ pub struct CallStats {
pub fec_recovered: u64, pub fec_recovered: u64,
/// Current mic audio level (RMS of i16 samples, 0-32767). /// Current mic audio level (RMS of i16 samples, 0-32767).
pub audio_level: u32, pub audio_level: u32,
/// Number of participants in the room (from last RoomUpdate).
pub room_participant_count: u32,
/// Participant list (fingerprint + optional alias) serialized as JSON array.
pub room_participants: Vec<RoomMember>,
}
/// A room member entry, serialized into the stats JSON.
#[derive(Clone, Debug, Default, serde::Serialize)]
pub struct RoomMember {
pub fingerprint: String,
pub alias: Option<String>,
} }

View File

@@ -26,7 +26,7 @@ pub use codec_id::{CodecId, QualityProfile};
pub use error::*; pub use error::*;
pub use packet::{ pub use packet::{
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
}; };
pub use bandwidth::{BandwidthEstimator, CongestionState}; pub use bandwidth::{BandwidthEstimator, CongestionState};
pub use quality::{AdaptiveQualityController, NetworkContext, Tier}; pub use quality::{AdaptiveQualityController, NetworkContext, Tier};

View File

@@ -645,6 +645,23 @@ pub enum SignalMessage {
session_id: String, session_id: String,
room_name: String, room_name: String,
}, },
/// Room membership update — sent by relay to all participants when someone joins or leaves.
RoomUpdate {
/// Current participant count.
count: u32,
/// List of participants currently in the room.
participants: Vec<RoomParticipant>,
},
}
/// A participant entry in a RoomUpdate message.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RoomParticipant {
/// Identity fingerprint (hex string, stable across reconnects if seed is persisted).
pub fingerprint: String,
/// Optional display name set by the client.
pub alias: Option<String>,
} }
/// Reasons for ending a call. /// Reasons for ending a call.

View File

@@ -502,14 +502,21 @@ async fn main() -> anyhow::Result<()> {
let participant_id = { let participant_id = {
let mut mgr = room_mgr.lock().await; let mut mgr = room_mgr.lock().await;
match mgr.join(&room_name, addr, room::ParticipantSender::Quic(transport.clone()), authenticated_fp.as_deref()) { match mgr.join(
Ok(id) => { &room_name,
addr,
room::ParticipantSender::Quic(transport.clone()),
authenticated_fp.as_deref(),
None, // alias — TODO: accept from client
) {
Ok((id, update, senders)) => {
metrics.active_rooms.set(mgr.list().len() as i64); metrics.active_rooms.set(mgr.list().len() as i64);
drop(mgr); // release lock before async broadcast
room::broadcast_signal(&senders, &update).await;
id id
} }
Err(e) => { Err(e) => {
error!(%addr, room = %room_name, "room join denied: {e}"); error!(%addr, room = %room_name, "room join denied: {e}");
// Clean up the session we just created
metrics.active_sessions.dec(); metrics.active_sessions.dec();
let mut smgr = session_mgr.lock().await; let mut smgr = session_mgr.lock().await;
smgr.remove_session(session_id); smgr.remove_session(session_id);

View File

@@ -67,11 +67,24 @@ impl ParticipantSender {
} }
} }
/// Broadcast a signal message to a list of participant senders.
pub async fn broadcast_signal(senders: &[ParticipantSender], msg: &wzp_proto::SignalMessage) {
for sender in senders {
if let ParticipantSender::Quic(t) = sender {
if let Err(e) = t.send_signal(msg).await {
warn!("broadcast_signal error: {e}");
}
}
}
}
/// A participant in a room. /// A participant in a room.
struct Participant { struct Participant {
id: ParticipantId, id: ParticipantId,
_addr: std::net::SocketAddr, _addr: std::net::SocketAddr,
sender: ParticipantSender, sender: ParticipantSender,
fingerprint: Option<String>,
alias: Option<String>,
} }
/// A room holding multiple participants. /// A room holding multiple participants.
@@ -86,10 +99,16 @@ impl Room {
} }
} }
fn add(&mut self, addr: std::net::SocketAddr, sender: ParticipantSender) -> ParticipantId { fn add(
&mut self,
addr: std::net::SocketAddr,
sender: ParticipantSender,
fingerprint: Option<String>,
alias: Option<String>,
) -> ParticipantId {
let id = next_id(); let id = next_id();
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room"); info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
self.participants.push(Participant { id, _addr: addr, sender }); self.participants.push(Participant { id, _addr: addr, sender, fingerprint, alias });
id id
} }
@@ -106,6 +125,22 @@ impl Room {
.collect() .collect()
} }
/// Build a RoomUpdate participant list.
fn participant_list(&self) -> Vec<wzp_proto::packet::RoomParticipant> {
self.participants
.iter()
.map(|p| wzp_proto::packet::RoomParticipant {
fingerprint: p.fingerprint.clone().unwrap_or_default(),
alias: p.alias.clone(),
})
.collect()
}
/// Get all senders (for broadcasting to everyone including the joiner).
fn all_senders(&self) -> Vec<ParticipantSender> {
self.participants.iter().map(|p| p.sender.clone()).collect()
}
fn is_empty(&self) -> bool { fn is_empty(&self) -> bool {
self.participants.is_empty() self.participants.is_empty()
} }
@@ -165,20 +200,27 @@ impl RoomManager {
} }
} }
/// Join a room. Returns the participant ID or an error if unauthorized. /// Join a room. Returns (participant_id, room_update_msg, all_senders) for broadcasting.
pub fn join( pub fn join(
&mut self, &mut self,
room_name: &str, room_name: &str,
addr: std::net::SocketAddr, addr: std::net::SocketAddr,
sender: ParticipantSender, sender: ParticipantSender,
fingerprint: Option<&str>, fingerprint: Option<&str>,
) -> Result<ParticipantId, String> { alias: Option<&str>,
) -> Result<(ParticipantId, wzp_proto::SignalMessage, Vec<ParticipantSender>), String> {
if !self.is_authorized(room_name, fingerprint) { if !self.is_authorized(room_name, fingerprint) {
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt"); warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
return Err("not authorized for this room".to_string()); return Err("not authorized for this room".to_string());
} }
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new); let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
Ok(room.add(addr, sender)) let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
let update = wzp_proto::SignalMessage::RoomUpdate {
count: room.len() as u32,
participants: room.participant_list(),
};
let senders = room.all_senders();
Ok((id, update, senders))
} }
/// Join a room via WebSocket. Convenience wrapper around `join()`. /// Join a room via WebSocket. Convenience wrapper around `join()`.
@@ -189,17 +231,27 @@ impl RoomManager {
sender: tokio::sync::mpsc::Sender<Bytes>, sender: tokio::sync::mpsc::Sender<Bytes>,
fingerprint: Option<&str>, fingerprint: Option<&str>,
) -> Result<ParticipantId, String> { ) -> Result<ParticipantId, String> {
self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint) let (id, _update, _senders) = self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint, None)?;
Ok(id)
} }
/// Leave a room. Removes the room if empty. /// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) { pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
if let Some(room) = self.rooms.get_mut(room_name) { if let Some(room) = self.rooms.get_mut(room_name) {
room.remove(participant_id); room.remove(participant_id);
if room.is_empty() { if room.is_empty() {
self.rooms.remove(room_name); self.rooms.remove(room_name);
info!(room = room_name, "room closed (empty)"); info!(room = room_name, "room closed (empty)");
return None;
} }
let update = wzp_proto::SignalMessage::RoomUpdate {
count: room.len() as u32,
participants: room.participant_list(),
};
let senders = room.all_senders();
Some((update, senders))
} else {
None
} }
} }
@@ -386,9 +438,12 @@ async fn run_participant_plain(
} }
} }
// Clean up // Clean up — leave room and broadcast update to remaining participants
let mut mgr = room_mgr.lock().await; let mut mgr = room_mgr.lock().await;
mgr.leave(&room_name, participant_id); if let Some((update, senders)) = mgr.leave(&room_name, participant_id) {
drop(mgr); // release lock before async broadcast
broadcast_signal(&senders, &update).await;
}
} }
/// Trunked forwarding loop — batches outgoing packets per peer. /// Trunked forwarding loop — batches outgoing packets per peer.
@@ -497,7 +552,10 @@ async fn run_participant_trunked(
} }
let mut mgr = room_mgr.lock().await; let mut mgr = room_mgr.lock().await;
mgr.leave(&room_name, participant_id); if let Some((update, senders)) = mgr.leave(&room_name, participant_id) {
drop(mgr);
broadcast_signal(&senders, &update).await;
}
} }
/// Parse up to the first 2 bytes of a hex session-id string into `[u8; 2]`. /// Parse up to the first 2 bytes of a hex session-id string into `[u8; 2]`.

Binary file not shown.