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:
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]`.
|
||||||
|
|||||||
BIN
wzp-release.apk
BIN
wzp-release.apk
Binary file not shown.
Reference in New Issue
Block a user