| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-10-04 13:42:49 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-10-04 14:37:27 UTC |
| parent | c4d72fc766a0eefdd48a57995ac4f67f65e68e1d |
| xmpp/Chat.hx | +27 | -4 |
| xmpp/Client.hx | +35 | -5 |
| xmpp/jingle/Session.hx | +177 | -62 |
diff --git a/xmpp/Chat.hx b/xmpp/Chat.hx index 2343135..25b5943 100644 --- a/xmpp/Chat.hx +++ b/xmpp/Chat.hx @@ -1,13 +1,14 @@ package xmpp; -import xmpp.ID; import haxe.io.BytesData; -import xmpp.MessageSync; -import xmpp.ChatMessage; import xmpp.Chat; +import xmpp.ChatMessage; +import xmpp.Color; import xmpp.GenericStream; +import xmpp.ID; +import xmpp.MessageSync; +import xmpp.jingle.Session; import xmpp.queries.MAMQuery; -import xmpp.Color; using Lambda; enum ChatType { @@ -64,6 +65,28 @@ abstract class Chat { return this.trusted; } + public function canAudioCall():Bool { + for (resource => cap in caps) { + if (cap.features.contains("urn:xmpp:jingle:apps:rtp:audio")) return true; + } + + return false; + } + + public function canVideoCall():Bool { + for (resource => cap in caps) { + if (cap.features.contains("urn:xmpp:jingle:apps:rtp:video")) return true; + } + + return false; + } + + public function startCall(audio: Bool, video: Bool) { + final session = new OutgoingProposedSession(client, JID.parse(chatId)); + jingleSessions.set(session.sid, session); + session.propose(audio, video); + } + public function acceptCall() { for (session in jingleSessions) { session.accept(); diff --git a/xmpp/Client.hx b/xmpp/Client.hx index 587a437..a3c4253 100644 --- a/xmpp/Client.hx +++ b/xmpp/Client.hx @@ -96,6 +96,29 @@ class Client extends xmpp.EventEmitter { } } + final jmiPro = stanza.getChild("proceed", "urn:xmpp:jingle-message:0"); + if (jmiPro != null && jmiPro.attr.get("id") != null) { + final chat = getDirectChat(from.asBare().asString()); + final session = chat.jingleSessions.get(jmiPro.attr.get("id")); + if (session != null) { + try { + chat.jingleSessions.set(session.sid, session.initiate(stanza)); + } catch (e) { + trace("JMI proceed failed", e); + } + } + } + + final jmiRej = stanza.getChild("reject", "urn:xmpp:jingle-message:0"); + if (jmiRej != null && jmiRej.attr.get("id") != null) { + final chat = getDirectChat(from.asBare().asString()); + final session = chat.jingleSessions.get(jmiRej.attr.get("id")); + if (session != null) { + session.retract(); + chat.jingleSessions.remove(session.sid); + } + } + final chatMessage = ChatMessage.fromStanza(stanza, jid); if (chatMessage != null) { var chat = getDirectChat(chatMessage.conversation()); @@ -154,21 +177,28 @@ class Client extends xmpp.EventEmitter { final session = chat.jingleSessions.get(jingle.attr.get("sid")); if (jingle.attr.get("action") == "session-initiate") { - final newSession = new xmpp.jingle.InitiatedSession(this, stanza); if (session != null) { - final nextSession = session.initiate(newSession); - if (nextSession == null) { + try { + chat.jingleSessions.set(session.sid, session.initiate(stanza)); + } catch (e) { chat.jingleSessions.remove(session.sid); - } else { - chat.jingleSessions.set(session.sid, nextSession); } } else { + final newSession = xmpp.jingle.InitiatedSession.fromSessionInitiate(this, stanza); chat.jingleSessions.set(session.sid, newSession); chatActivity(chat); newSession.ring(); } } + if (session != null && jingle.attr.get("action") == "session-accept") { + try { + chat.jingleSessions.set(session.sid, session.initiate(stanza)); + } catch (e) { + trace("session-accept failed", e); + } + } + if (session != null && jingle.attr.get("action") == "session-terminate") { session.terminate(); chat.jingleSessions.remove(jingle.attr.get("sid")); diff --git a/xmpp/jingle/Session.hx b/xmpp/jingle/Session.hx index ce7bad9..c6967ea 100644 --- a/xmpp/jingle/Session.hx +++ b/xmpp/jingle/Session.hx @@ -7,7 +7,7 @@ using Lambda; interface Session { public var sid (get, null): String; - public function initiate(session: InitiatedSession): Null<InitiatedSession>; + public function initiate(stanza: Stanza): InitiatedSession; public function accept(): Void; public function hangup(): Void; public function retract(): Void; @@ -47,11 +47,11 @@ class IncomingProposedSession implements Session { } public function terminate() { - trace("Tried to terminate before session-inititate: " + sid, this); + trace("Tried to terminate before session-initiate: " + sid, this); } public function transportInfo(_) { - trace("Got transport-info before session-inititate: " + sid, this); + trace("Got transport-info before session-initiate: " + sid, this); return Promise.resolve(null); } @@ -66,10 +66,11 @@ class IncomingProposedSession implements Session { ); } - public function initiate(session: InitiatedSession) { + public function initiate(stanza: Stanza) { // TODO: check if new session has corrent media - if (session.sid != sid) return null; - if (!accepted) return null; + final session = InitiatedSession.fromSessionInitiate(client, stanza); + if (session.sid != sid) throw "id mismatch"; + if (!accepted) throw "trying to initiate unaccepted session"; session.accept(); return session; } @@ -87,30 +88,126 @@ class IncomingProposedSession implements Session { } } +class OutgoingProposedSession implements Session { + public var sid (get, null): String; + private final client: Client; + private final to: JID; + private final _sid: String; + private var audio = false; + private var video = false; + + public function new(client: Client, to: JID) { + this.client = client; + this.to = to; + this._sid = ID.long(); + } + + public function propose(audio: Bool, video: Bool) { + this.audio = audio; + this.video = video; + final stanza = new Stanza("message", { to: to.asString(), type: "chat" }) + .tag("propose", { xmlns: "urn:xmpp:jingle-message:0", id: sid }); + if (audio) { + stanza.tag("description", { xmlns: "urn:xmpp:jingle:apps:rtp:1", media: "audio" }).up(); + } + if (video) { + stanza.tag("description", { xmlns: "urn:xmpp:jingle:apps:rtp:1", media: "video" }).up(); + } + stanza.up().tag("store", { xmlns: "urn:xmpp:hints" }); + client.sendStanza(stanza); + } + + public function ring() { + trace("Tried to accept before initiate: " + sid, this); + } + + public function hangup() { + client.sendStanza( + new Stanza("message", { to: to.asString(), type: "chat" }) + .tag("retract", { xmlns: "urn:xmpp:jingle-message:0", id: sid }).up() + .tag("store", { xmlns: "urn:xmpp:hints" }) + ); + client.getDirectChat(to.asBare().asString(), false).jingleSessions.remove(sid); + } + + public function retract() { + // Other side rejected the call + client.trigger("call/retract", { chatId: to.asBare().asString() }); + } + + public function terminate() { + trace("Tried to terminate before session-initiate: " + sid, this); + } + + public function transportInfo(_) { + trace("Got transport-info before session-initiate: " + sid, this); + return Promise.resolve(null); + } + + public function accept() { + trace("Tried to accept before initiate: " + sid, this); + } + + public function initiate(stanza: Stanza) { + final jmi = stanza.getChild("proceed", "urn:xmpp:jingle-message:0"); + if (jmi == null) throw "no jmi: " + stanza; + if (jmi.attr.get("id") != sid) throw "sid doesn't match: " + jmi.attr.get("id") + " vs " + sid; + client.sendPresence(to.asString()); + final session = new OutgoingSession(client, JID.parse(stanza.attr.get("from")), sid); + client.trigger("call/media", { session: session, audio: audio, video: video }); + return session; + } + + public function callStatus() { + return "outgoing"; + } + + public function videoTracks() { + return []; + } + + private function get_sid() { + return this._sid; + } +} + class InitiatedSession implements Session { public var sid (get, null): String; private final client: Client; - private final sessionInitiate: Stanza; - private final session: SessionDescription; - private var answer: Null<SessionDescription> = null; + private final counterpart: JID; + private final _sid: String; + private var remoteDescription: Null<SessionDescription> = null; + private var localDescription: Null<SessionDescription> = null; private var pc: PeerConnection = null; private final queuedInboundTransportInfo: Array<Stanza> = []; private final queuedOutboundCandidate: Array<{ candidate: String, sdpMid: String, usernameFragment: String }> = []; private var accepted: Bool = false; - public function new(client: Client, sessionInitiate: Stanza) { + public function new(client: Client, counterpart: JID, sid: String, remoteDescription: Null<SessionDescription>) { this.client = client; - this.sessionInitiate = sessionInitiate; - this.session = SessionDescription.fromStanza(sessionInitiate, false); + this.counterpart = counterpart; + this._sid = sid; + this.remoteDescription = remoteDescription; + } + + public static function fromSessionInitiate(client: Client, stanza: Stanza): InitiatedSession { + final jingle = stanza.getChild("jingle", "urn:xmpp:jingle:1"); + final session = new InitiatedSession( + client, + JID.parse(stanza.attr.get("from")), + jingle.attr.get("sid"), + SessionDescription.fromStanza(stanza, false) + ); + session.transportInfo(stanza); // Add any candidates from the initiate + return session; } public function get_sid() { - final jingle = sessionInitiate.getChild("jingle", "urn:xmpp:jingle:1"); - return jingle.attr.get("sid"); + return _sid; } public function ring() { - client.trigger("call/ring", { chatId: JID.parse(sessionInitiate.attr.get("from")).asBare().asString(), session: this }); + client.trigger("call/ring", { chatId: counterpart.asBare().asString(), session: this }); } public function retract() { @@ -118,25 +215,27 @@ class InitiatedSession implements Session { } public function accept() { - if (accepted) return; + if (accepted || remoteDescription == null) return; accepted = true; - client.trigger("call/media", { session: this }); + final audio = remoteDescription.media.find((m) -> m.media == "audio") != null; + final video = remoteDescription.media.find((m) -> m.media == "video") != null; + client.trigger("call/media", { session: this, audio: audio, video: video }); } public function hangup() { client.sendStanza( - new Stanza("iq", { to: sessionInitiate.attr.get("from"), type: "set", id: ID.medium() }) + new Stanza("iq", { to: counterpart.asString(), type: "set", id: ID.medium() }) .tag("jingle", { xmlns: "urn:xmpp:jingle:1", action: "session-terminate", sid: sid }) .tag("reason").tag("success") .up().up().up() ); terminate(); - client.trigger("call/retract", { chatId: JID.parse(sessionInitiate.attr.get("from")).asBare().asString() }); + client.trigger("call/retract", { chatId: counterpart.asBare().asString() }); } - public function initiate(session: InitiatedSession) { - trace("Trying to inititate already initiated session: " + sid); - return null; + public function initiate(stanza: Stanza) { + trace("Trying to initiate already initiated session: " + sid); + return throw "already initiated"; } public function terminate() { @@ -157,7 +256,7 @@ class InitiatedSession implements Session { } return Promise.all(IceCandidate.fromStanza(stanza).map((candidate) -> { - final index = session.identificationTags.indexOf(candidate.sdpMid); + final index = remoteDescription.identificationTags.indexOf(candidate.sdpMid); return pc.addIceCandidate(untyped { candidate: candidate.toSdp(), sdpMid: candidate.sdpMid, @@ -181,12 +280,11 @@ class InitiatedSession implements Session { private function sendIceCandidate(candidate: { candidate: String, sdpMid: String, usernameFragment: String }) { if (candidate == null) return; // All candidates received now if (candidate.candidate == "") return; // All candidates received now - if (answer == null) { + if (localDescription == null) { queuedOutboundCandidate.push(candidate); return; } - final jingle = sessionInitiate.getChild("jingle", "urn:xmpp:jingle:1"); - final media = answer.media.find((media) -> media.mid == candidate.sdpMid); + final media = localDescription.media.find((media) -> media.mid == candidate.sdpMid); if (media == null) throw "Unknown media: " + candidate.sdpMid; final transportInfo = new TransportInfo( new Media( @@ -202,19 +300,18 @@ class InitiatedSession implements Session { ], media.formats ), - jingle.attr.get("sid") + sid ).toStanza(false); - transportInfo.attr.set("to", sessionInitiate.attr.get("from")); + transportInfo.attr.set("to", counterpart.asString()); transportInfo.attr.set("id", ID.medium()); client.sendStanza(transportInfo); } public function supplyMedia(streams: Array<MediaStream>) { - final jingle = sessionInitiate.getChild("jingle", "urn:xmpp:jingle:1"); client.getIceServers((servers) -> { pc = new PeerConnection({ iceServers: servers }); pc.addEventListener("track", (event) -> { - client.trigger("call/track", { chatId: JID.parse(sessionInitiate.attr.get("from")).asBare().asString(), track: event.track, streams: event.streams }); + client.trigger("call/track", { chatId: counterpart.asBare().asString(), track: event.track, streams: event.streams }); }); pc.addEventListener("negotiationneeded", (event) -> trace("renegotiate", event)); pc.addEventListener("icecandidate", (event) -> { @@ -225,38 +322,56 @@ class InitiatedSession implements Session { pc.addTrack(track, stream); } } - pc.setRemoteDescription({ type: SdpType.OFFER, sdp: session.toSdp() }) - .then((_) -> { - final inboundTransportInfo = queuedInboundTransportInfo.copy(); - queuedInboundTransportInfo.resize(0); - return Promise.all(IceCandidate.fromStanza(sessionInitiate).map((candidate) -> { - final index = session.identificationTags.indexOf(candidate.sdpMid); - return pc.addIceCandidate(untyped { - candidate: candidate.toSdp(), - sdpMid: candidate.sdpMid, - sdpMLineIndex: index < 0 ? null : index, - usernameFragment: candidate.ufrag - }); - }).concat(inboundTransportInfo.map(transportInfo))); - }) - .then((_) -> pc.setLocalDescription(null)) - .then((_) -> { - answer = SessionDescription.parse(pc.localDescription.sdp); - final sessionAccept = answer.toStanza("session-accept", jingle.attr.get("sid"), false); - sessionAccept.attr.set("to", sessionInitiate.attr.get("from")); - sessionAccept.attr.set("id", ID.medium()); - client.sendStanza(sessionAccept); - - final outboundCandidate = queuedOutboundCandidate.copy(); - queuedOutboundCandidate.resize(0); - for (candidate in outboundCandidate) { - sendIceCandidate(candidate); - } - }) - .catchError((e) -> { - trace("acceptJingleRtp error", e); - pc.close(); - }); + + onPeerConnection().catchError((e) -> { + trace("supplyMedia error", e); + pc.close(); + }); + }); + } + + private function setupLocalDescription(type: String) { + return pc.setLocalDescription(null).then((_) -> { + localDescription = SessionDescription.parse(pc.localDescription.sdp); + final sessionAccept = localDescription.toStanza(type, sid, false); + sessionAccept.attr.set("to", counterpart.asString()); + sessionAccept.attr.set("id", ID.medium()); + client.sendStanza(sessionAccept); + + final outboundCandidate = queuedOutboundCandidate.copy(); + queuedOutboundCandidate.resize(0); + for (candidate in outboundCandidate) { + sendIceCandidate(candidate); + } + }); + } + + private function onPeerConnection() { + return pc.setRemoteDescription({ type: SdpType.OFFER, sdp: remoteDescription.toSdp() }) + .then((_) -> { + final inboundTransportInfo = queuedInboundTransportInfo.copy(); + queuedInboundTransportInfo.resize(0); + return inboundTransportInfo.map(transportInfo); + }) + .then((_) -> { + setupLocalDescription("session-accept"); }); } } + +class OutgoingSession extends InitiatedSession { + public function new(client: Client, counterpart: JID, sid: String) { + super(client, counterpart, sid, null); + } + + private override function onPeerConnection() { + return setupLocalDescription("session-initiate"); + } + + public override function initiate(stanza: Stanza) { + remoteDescription = SessionDescription.fromStanza(stanza, true); + pc.setRemoteDescription({ type: SdpType.ANSWER, sdp: remoteDescription.toSdp() }) + .then((_) -> transportInfo(stanza)); + return this; + } +}