| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-09-27 18:24:23 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-09-27 18:26:43 UTC |
| parent | baa6311d282eca78370218fd103f68dc7e41e4d7 |
| test.hxml | +6 | -0 |
| test/TestAll.hx | +10 | -0 |
| test/TestSessionDescription.hx | +110 | -0 |
| xmpp/jingle/Group.hx | +40 | -0 |
| xmpp/jingle/PeerConnection.js.hx | +8 | -0 |
| xmpp/jingle/Session.hx | +140 | -0 |
| xmpp/jingle/SessionDescription.hx | +560 | -0 |
diff --git a/test.hxml b/test.hxml new file mode 100644 index 0000000..926f792 --- /dev/null +++ b/test.hxml @@ -0,0 +1,6 @@ +--library haxe-strings +--library hsluv +--library utest + +--run +test.TestAll diff --git a/test/TestAll.hx b/test/TestAll.hx new file mode 100644 index 0000000..f8b324b --- /dev/null +++ b/test/TestAll.hx @@ -0,0 +1,10 @@ +package test; + +import utest.Runner; +import utest.ui.Report; + +class TestAll { + public static function main() { + utest.UTest.run([new TestSessionDescription()]); + } +} diff --git a/test/TestSessionDescription.hx b/test/TestSessionDescription.hx new file mode 100644 index 0000000..7b54e89 --- /dev/null +++ b/test/TestSessionDescription.hx @@ -0,0 +1,110 @@ +package test; + +import utest.Assert; +import utest.Async; +import xmpp.Stanza; +import xmpp.jingle.SessionDescription; + +class TestSessionDescription extends utest.Test { + final stanzaSource = '<iq type="set"><jingle sid="kxcebFwaWUQTQQO5sUoJJA" action="session-initiate" xmlns="urn:xmpp:jingle:1"><group semantics="BUNDLE" xmlns="urn:xmpp:jingle:apps:grouping:0"><content name="0"/></group><content name="0" creator="initiator" xmlns="urn:xmpp:jingle:1"><description media="audio" xmlns="urn:xmpp:jingle:apps:rtp:1"><payload-type channels="2" name="opus" clockrate="48000" id="111" xmlns="urn:xmpp:jingle:apps:rtp:1"><parameter name="minptime" value="10" xmlns="urn:xmpp:jingle:apps:rtp:1"/><parameter name="useinbandfec" value="1" xmlns="urn:xmpp:jingle:apps:rtp:1"/><rtcp-fb type="transport-cc" xmlns="urn:xmpp:jingle:apps:rtp:rtcp-fb:0"/></payload-type><payload-type channels="2" name="red" clockrate="48000" id="63" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="ISAC" clockrate="16000" id="103" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="ISAC" clockrate="32000" id="104" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="G722" clockrate="8000" id="9" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="ILBC" clockrate="8000" id="102" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="PCMU" clockrate="8000" id="0" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="PCMA" clockrate="8000" id="8" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="CN" clockrate="32000" id="106" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="CN" clockrate="16000" id="105" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="CN" clockrate="8000" id="13" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="telephone-event" clockrate="48000" id="110" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="telephone-event" clockrate="32000" id="112" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="telephone-event" clockrate="16000" id="113" xmlns="urn:xmpp:jingle:apps:rtp:1"/><payload-type name="telephone-event" clockrate="8000" id="126" xmlns="urn:xmpp:jingle:apps:rtp:1"/><rtp-hdrext uri="urn:ietf:params:rtp-hdrext:ssrc-audio-level" id="1" xmlns="urn:xmpp:jingle:apps:rtp:rtp-hdrext:0"/><rtp-hdrext uri="http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time" id="2" xmlns="urn:xmpp:jingle:apps:rtp:rtp-hdrext:0"/><rtp-hdrext uri="http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01" id="3" xmlns="urn:xmpp:jingle:apps:rtp:rtp-hdrext:0"/><rtp-hdrext uri="urn:ietf:params:rtp-hdrext:sdes:mid" id="4" xmlns="urn:xmpp:jingle:apps:rtp:rtp-hdrext:0"/><extmap-allow-mixed xmlns="urn:xmpp:jingle:apps:rtp:rtp-hdrext:0"/><source ssrc="3713170236" xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"><parameter name="cname" value="KP3+b0QvXtga40Uo"/><parameter name="msid" value="- audio-track-76673976-daf1-4afa-bed8-1277ddc1c7f7"/></source><rtcp-mux/></description><transport pwd="88yXZajgGS00ziBDQ0yLtr9t" ufrag="r8tL" xmlns="urn:xmpp:jingle:transports:ice-udp:1"><fingerprint setup="actpass" hash="sha-256" xmlns="urn:xmpp:jingle:apps:dtls:0">26:58:C5:C5:68:7A:BA:C9:11:2D:6D:A3:C5:57:16:4C:E0:A0:46:06:FA:49:62:1B:54:E4:A5:F1:CB:89:18:43</fingerprint></transport></content></jingle></iq>'; + final sdpExample = + "v=0\r\n" + + "o=- 8770656990916039506 2 IN IP4 127.0.0.1\r\n" + + "s=-\r\n" + + "t=0 0\r\n" + + "a=group:BUNDLE 0\r\n" + + "a=msid-semantic:WMS my-media-stream\r\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111 63 103 104 9 102 0 8 106 105 13 110 112 113 126\r\n"+ + "c=IN IP4 0.0.0.0\r\n" + + "a=ice-ufrag:r8tL\r\n" + + "a=ice-pwd:88yXZajgGS00ziBDQ0yLtr9t\r\n" + + "a=ice-options:trickle\r\n" + + "a=fingerprint:sha-256 26:58:C5:C5:68:7A:BA:C9:11:2D:6D:A3:C5:57:16:4C:E0:A0:46:06:FA:49:62:1B:54:E4:A5:F1:CB:89:18:43\r\n" + + "a=setup:actpass\r\n" + + "a=rtpmap:111 opus/48000/2\r\n" + + "a=fmtp:111 minptime=10;useinbandfec=1\r\n" + + "a=rtcp-fb:111 transport-cc\r\n" + + "a=rtpmap:63 red/48000/2\r\n" + + "a=rtpmap:103 ISAC/16000\r\n" + + "a=rtpmap:104 ISAC/32000\r\n" + + "a=rtpmap:9 G722/8000\r\n" + + "a=rtpmap:102 ILBC/8000\r\n" + + "a=rtpmap:0 PCMU/8000\r\n" + + "a=rtpmap:8 PCMA/8000\r\n" + + "a=rtpmap:106 CN/32000\r\n" + + "a=rtpmap:105 CN/16000\r\n" + + "a=rtpmap:13 CN/8000\r\n" + + "a=rtpmap:110 telephone-event/48000\r\n" + + "a=rtpmap:112 telephone-event/32000\r\n" + + "a=rtpmap:113 telephone-event/16000\r\n" + + "a=rtpmap:126 telephone-event/8000\r\n" + + "a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" + + "a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" + + "a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" + + "a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n" + + "a=extmap-allow-mixed\r\n" + + "a=ssrc:3713170236 cname:KP3+b0QvXtga40Uo\r\n" + + "a=ssrc:3713170236 msid:- audio-track-76673976-daf1-4afa-bed8-1277ddc1c7f7\r\n" + + "a=mid:0\r\n" + + "a=sendrecv\r\n" + + "a=rtcp-mux\r\n" + + "a=rtcp:9 IN IP4 0.0.0.0\r\n"; + + final sdpExampleAnswer = + "v=0\r\n" + + "o=mozilla...THIS_IS_SDPARTA-99.0 4223511214128453424 0 IN IP4 0.0.0.0\r\n" + + "s=-\r\n" + + "t=0 0\r\n" + + "a=sendrecv\r\n" + + "a=fingerprint:sha-256 64:09:24:0A:A4:75:84:7D:61:6F:78:13:83:AB:8B:57:6E:E9:EF:39:FC:3A:92:17:AA:8C:0C:C1:9D:74:61:DF\r\n" + + "a=group:BUNDLE 0\r\n" + + "a=ice-options:trickle\r\n" + + "a=msid-semantic:WMS *\r\n" + + "m=audio 52831 UDP/TLS/RTP/SAVPF 111 9 0 8 126\r\n" + + "c=IN IP4 127.0.0.1\r\n" + + "a=candidate:0 1 UDP 2122252543 78677bb1-7843-4445-b8e7-44449283c4e1.local 38038 typ host\r\n" + + "a=candidate:3 1 TCP 2105524479 78677bb1-7843-4445-b8e7-44449283c4e1.local 9 typ host tcptype active\r\n" + + "a=candidate:1 1 UDP 1686052863 127.0.0.1 38038 typ srflx raddr 0.0.0.0 rport 0\r\n" + + "a=candidate:2 1 UDP 92216319 127.0.0.1 52831 typ relay raddr 127.0.0.1 rport 52831\r\n" + + "a=candidate:4 1 UDP 8331263 127.0.0.1 55366 typ relay raddr 127.0.0.1 rport 55366\r\n" + + "a=recvonly\r\n" + + "a=end-of-candidates\r\n" + + "a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" + + "a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n" + + "a=fmtp:111 maxplaybackrate=48000;stereo=1;useinbandfec=1\r\n" + + "a=fmtp:126 0-15\r\n" + + "a=ice-pwd:4e45923f3734c434427d7bd002c79fd4\r\n" + + "a=ice-ufrag:fe3fcb84\r\n" + + "a=mid:0\r\n" + + "a=rtcp-mux\r\n" + + "a=rtpmap:111 opus/48000/2\r\n" + + "a=rtpmap:9 G722/8000/1\r\n" + + "a=rtpmap:0 PCMU/8000\r\n" + + "a=rtpmap:8 PCMA/8000\r\n" + + "a=rtpmap:126 telephone-event/8000\r\n" + + "a=setup:active\r\n" + + "a=ssrc:691851057 cname:{df71a836-615d-4bab-bf3e-7f2ee9d2f0a1}\r\n"; + + public function testConvertStanzaToSDP() { + final session = SessionDescription.fromStanza(Stanza.fromXml(Xml.parse(stanzaSource)), false); + Assert.equals(sdpExample, session.toSdp()); + } + + public function testConvertSDPToSDP() { + final session = SessionDescription.parse(sdpExample); + Assert.equals(sdpExample, session.toSdp()); + } + + public function testConvertSDPToStanzaAndBack() { + final session = SessionDescription.parse(sdpExample); + Assert.equals( + sdpExample, + SessionDescription.fromStanza(session.toStanza("session-initiate", "kxcebFwaWUQTQQO5sUoJJA", false), false).toSdp() + ); + } + + public function testConvertSDPAnswerToStanza() { + final stanza = SessionDescription.parse(sdpExampleAnswer).toStanza("session-accept", "sid", false); + Assert.notNull(stanza.getChild("jingle", "urn:xmpp:jingle:1").getChild("content").getChild("transport", "urn:xmpp:jingle:transports:ice-udp:1").getChild("candidate")); + } +} diff --git a/xmpp/jingle/Group.hx b/xmpp/jingle/Group.hx new file mode 100644 index 0000000..324e6cd --- /dev/null +++ b/xmpp/jingle/Group.hx @@ -0,0 +1,40 @@ +package xmpp.jingle; + +class Group { + public var semantics (default, null): String; + public var identificationTags (default, null): Array<String>; + + public function new(semantics: String, identificationTags: Array<String>) { + this.semantics = semantics; + this.identificationTags = identificationTags; + } + + public static function parse(input: String) { + final segments = input.split(" "); + if (segments.length < 2) return null; + return new Group(segments[0], segments.slice(1)); + } + + public static function fromElement(el: Stanza) { + final idTags = []; + for (content in el.allTags("content")) { + if (content.attr.get("name") != null) idTags.push(content.attr.get("name")); + } + return new Group(el.attr.get("semantics"), idTags); + } + + public function toSdp() { + if (semantics.indexOf(" ") >= 0) { + throw "Group semantics cannot contain a space in SDP"; + } + return semantics + " " + identificationTags.join(" "); + } + + public function toElement() { + final group = new Stanza("group", { xmlns: "urn:xmpp:jingle:apps:grouping:0", semantics: semantics }); + for (tag in identificationTags) { + group.tag("content", { name: tag }).up(); + } + return group; + } +} diff --git a/xmpp/jingle/PeerConnection.js.hx b/xmpp/jingle/PeerConnection.js.hx new file mode 100644 index 0000000..ae07cae --- /dev/null +++ b/xmpp/jingle/PeerConnection.js.hx @@ -0,0 +1,8 @@ +package xmpp.jingle; + +import js.html.rtc.PeerConnection; + +typedef PeerConnection = js.html.rtc.PeerConnection; +typedef SdpType = js.html.rtc.SdpType; +typedef Promise<T> = js.lib.Promise<T>; +typedef MediaStream = js.html.MediaStream; diff --git a/xmpp/jingle/Session.hx b/xmpp/jingle/Session.hx new file mode 100644 index 0000000..0105d2d --- /dev/null +++ b/xmpp/jingle/Session.hx @@ -0,0 +1,140 @@ +package xmpp.jingle; + +import xmpp.jingle.PeerConnection; +import xmpp.jingle.SessionDescription; +using Lambda; + +class 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 var pc: PeerConnection = null; + private final queuedInboundTransportInfo: Array<Stanza> = []; + private final queuedOutboundCandidate: Array<{ candidate: String, sdpMid: String, usernameFragment: String }> = []; + + public function new(client: Client, sessionInitiate: Stanza) { + this.client = client; + this.sessionInitiate = sessionInitiate; + this.session = SessionDescription.fromStanza(sessionInitiate, false); + } + + public function get_sid() { + final jingle = sessionInitiate.getChild("jingle", "urn:xmpp:jingle:1"); + return jingle.attr.get("sid"); + } + + public function accept() { + client.trigger("call/media", { session: this }); + } + + public function terminate() { + if (pc == null) return; + pc.close(); + for (tranceiver in pc.getTransceivers()) { + if (tranceiver.sender != null && tranceiver.sender.track != null) { + tranceiver.sender.track.stop(); + } + } + } + + public function transportInfo(stanza: Stanza) { + if (pc == null) { + queuedInboundTransportInfo.push(stanza); + return Promise.resolve(null); + } + + return Promise.all(IceCandidate.fromStanza(stanza).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 + }); + })).then((_) -> {}); + } + + 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) { + queuedOutboundCandidate.push(candidate); + return; + } + final jingle = sessionInitiate.getChild("jingle", "urn:xmpp:jingle:1"); + final media = answer.media.find((media) -> media.mid == candidate.sdpMid); + if (media == null) throw "Unknown media: " + candidate.sdpMid; + final transportInfo = new TransportInfo( + new Media( + media.mid, + media.media, + media.connectionData, + media.port, + media.protocol, + [ + Attribute.parse(candidate.candidate), + new Attribute("ice-ufrag", candidate.usernameFragment), + media.attributes.find((attr) -> attr.key == "ice-pwd") + ], + media.formats + ), + jingle.attr.get("sid") + ).toStanza(false); + transportInfo.attr.set("to", sessionInitiate.attr.get("from")); + 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 }); + }); + pc.addEventListener("negotiationneeded", (event) -> trace("renegotiate", event)); + pc.addEventListener("icecandidate", (event) -> { + sendIceCandidate(event.candidate); + }); + for (stream in streams) { + for (track in stream.getTracks()) { + 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(); + }); + }); + } +} diff --git a/xmpp/jingle/SessionDescription.hx b/xmpp/jingle/SessionDescription.hx new file mode 100644 index 0000000..cf07a29 --- /dev/null +++ b/xmpp/jingle/SessionDescription.hx @@ -0,0 +1,560 @@ +package xmpp.jingle; + +import haxe.DynamicAccess; +using Lambda; + +class SessionDescription { + public var version (default, null): Int; + public var name (default, null): String; + public var media (default, null): Array<Media>; + public var attributes (default, null): Array<Attribute>; + public var identificationTags (default, null): Array<String>; + + public function new(version: Int, name: String, media: Array<Media>, attributes: Array<Attribute>, identificationTags: Array<String>) { + this.version = version; + this.name = name; + this.media = media; + this.attributes = attributes; + this.identificationTags = identificationTags; + } + + public static function parse(input: String) { + var version = 0; + var name = "-"; + var attributes = []; + final media: Array<Media> = []; + var currentAttributes: Array<Attribute> = []; + var currentMedia: Null<Dynamic> = null; + + for (line in input.split("\r\n")) { + if (line.indexOf("=") != 1) { + continue; // skip unknown format line + } + final value = line.substr(2); + switch(line.charAt(0)) { + case "v": + version = Std.parseInt(value); + case "s": + name = value; + case "a": + currentAttributes.push(Attribute.parse(value)); + case "m": + if (currentMedia == null) { + attributes = currentAttributes; + } else { + final mid = currentAttributes.find((attr) -> attr.key == "mid"); + media.push(new Media( + mid == null ? null : mid.value, + currentMedia.media, + currentMedia.connectionData, + currentMedia.port, + currentMedia.protocol, + currentAttributes, + currentMedia.formats + )); + } + currentAttributes = []; + final segments = value.split(" "); + if (segments.length >= 3) { + currentMedia = { + media: segments[0], + port: segments[1], + protocol: segments[2], + formats: segments.slice(3).map((format) -> Std.parseInt(format)) + } + } else { + currentMedia = {}; + } + case "c": + if (currentMedia != null) currentMedia.connectionData = value; + } + } + + if (currentMedia != null) { + final mid = currentAttributes.find((attr) -> attr.key == "mid"); + media.push(new Media( + mid == null ? null : mid.value, + currentMedia.media, + currentMedia.connectionData, + currentMedia.port, + currentMedia.protocol, + currentAttributes, + currentMedia.formats + )); + } else { + attributes = currentAttributes; + } + + var tags: Array<String>; + final group = attributes.find((attr) -> attr.key == "group"); + if (group != null) { + tags = Group.parse(group.value).identificationTags; + } else { + tags = media.map((m) -> m.mid); + } + + return new SessionDescription(version, name, media, attributes, tags); + } + + public static function fromStanza(iq: Stanza, initiator: Bool) { + final attributes: Array<Attribute> = []; + final jingle = iq.getChild("jingle", "urn:xmpp:jingle:1"); + final group = jingle.getChild("group", "urn:xmpp:jingle:apps:grouping:0"); + final media = jingle.allTags("content").map((el) -> Media.fromElement(el, initiator, group != null)); + + var tags: Array<String>; + if (group != null) { + final group = Group.fromElement(group); + attributes.push(new Attribute("group", group.toSdp())); + tags = group.identificationTags; + } else { + tags = media.map((m) -> m.mid); + } + attributes.push(new Attribute("msid-semantic", "WMS my-media-stream")); + + return new SessionDescription(0, "-", media, attributes, tags); + } + + public function toSdp() { + return + "v=" + version + "\r\n" + + "o=- 8770656990916039506 2 IN IP4 127.0.0.1\r\n" + + "s=" + name + "\r\n" + + "t=0 0\r\n" + + attributes.map((attr) -> attr.toSdp()).join("") + + media.map((media) -> media.toSdp()).join(""); + } + + public function toStanza(action: String, sid: String, initiator: Bool) { + final iq = new Stanza("iq", { type: "set" }); + final jingle = iq.tag("jingle", { xmlns: "urn:xmpp:jingle:1", action: action, sid: sid }); + final group = attributes.find((attr) -> attr.key == "group"); + if (group != null) { + jingle.addChild(Group.parse(group.value).toElement()); + } + for (m in media) { + jingle.addChild(m.toElement(attributes, initiator)); + } + jingle.up(); + return iq; + } +} + +class TransportInfo { + private final media: Media; + private final sid: String; + + public function new(media: Media, sid: String) { + this.media = media; + this.sid = sid; + } + + public function toStanza(initiator: Bool) { + final iq = new Stanza("iq", { type: "set" }); + final jingle = iq.tag("jingle", { xmlns: "urn:xmpp:jingle:1", action: "transport-info", sid: sid }); + jingle.addChild(media.contentElement(initiator).addChild(media.toTransportElement([])).up()); + jingle.up(); + return iq; + } +} + +class Media { + public var mid (default, null): String; + public var media (default, null): String; + public var connectionData (default, null): String; + public var port (default, null): String; + public var protocol (default, null): String; + public var attributes (default, null): Array<Attribute>; + public var formats (default, null): Array<Int>; + + public function new(mid: String, media: String, connectionData: String, port: String, protocol: String, attributes: Array<Attribute>, formats: Array<Int>) { + this.mid = mid; + this.media = media; + this.connectionData = connectionData; + this.port = port; + this.protocol = protocol; + this.attributes = attributes; + this.formats = formats; + } + + public static function fromElement(content: Stanza, initiator: Bool, hasGroup: Bool) { + final mediaAttributes: Array<Attribute> = []; + final mediaFormats: Array<Int> = []; + final mid = content.attr.get("name"); + final transport = content.getChild("transport", "urn:xmpp:jingle:transports:ice-udp:1"); + if (transport == null) throw "ice-udp transport is missing"; + + if (transport.attr.get("ufrag") == null) throw "transport is missing ufrag"; + final ufrag = transport.attr.get("ufrag"); + mediaAttributes.push(new Attribute("ice-ufrag", ufrag)); + + if (transport.attr.get("pwd") == null) throw "transport is missing pwd"; + mediaAttributes.push(new Attribute("ice-pwd", transport.attr.get("pwd"))); + mediaAttributes.push(new Attribute("ice-options", "trickle")); + + final fingerprint = transport.getChild("fingerprint", "urn:xmpp:jingle:apps:dtls:0"); + if (fingerprint != null) { + mediaAttributes.push(new Attribute("fingerprint", fingerprint.attr.get("hash") + " " + fingerprint.getText())); + if (fingerprint.attr.get("setup") != null) { + mediaAttributes.push(new Attribute("setup", fingerprint.attr.get("setup"))); + } + } + + final description = content.getChild("description", "urn:xmpp:jingle:apps:rtp:1"); + for (payloadType in description.allTags("payload-type")) { + final id = Std.parseInt(payloadType.attr.get("id")); + if (payloadType.attr.get("id") == null) throw "payload-type missing or invalid id"; + mediaFormats.push(id); + final clockRate = Std.parseInt(payloadType.attr.get("clockrate")); + final channels = Std.parseInt(payloadType.attr.get("channels")); + mediaAttributes.push(new Attribute("rtpmap", id + " " + payloadType.attr.get("name") + "/" + (clockRate == null ? 0 : clockRate) + (channels == null || channels == 1 ? "" : "/" + channels))); + + final parameters = payloadType.allTags("parameter").map((el) -> (el.attr.get("name") == null ? "" : el.attr.get("name") + "=") + el.attr.get("value")); + if (parameters.length > 0) { + mediaAttributes.push(new Attribute("fmtp", id + " " + parameters.join(";"))); + } + + for (feedbackNegotiation in payloadType.allTags("rtcp-fb", "urn:xmpp:jingle:apps:rtp:rtcp-fb:0")) { + final subtype = feedbackNegotiation.attr.get("subtype"); + mediaAttributes.push(new Attribute("rtcp-fb", id + " " + feedbackNegotiation.attr.get("type") + (subtype == null || subtype == "" ? "" : " " + subtype))); + } + + for (trrInt in payloadType.allTags("rtcp-fb-trr-int", "urn:xmpp:jingle:apps:rtp:rtcp-fb:0")) { + mediaAttributes.push(new Attribute("rtcp-fb", id + " trr-int " + trrInt.attr.get("value"))); + } + } + + for (feedbackNegotiation in description.allTags("rtcp-fb", "urn:xmpp:jingle:apps:rtp:rtcp-fb:0")) { + final subtype = feedbackNegotiation.attr.get("subtype"); + mediaAttributes.push(new Attribute("rtcp-fb", "* " + feedbackNegotiation.attr.get("type") + (subtype == null || subtype == "" ? "" : " " + subtype))); + } + + for (trrInt in description.allTags("rtcp-fb-trr-int", "urn:xmpp:jingle:apps:rtp:rtcp-fb:0")) { + mediaAttributes.push(new Attribute("rtcp-fb", "* trr-int " + trrInt.attr.get("value"))); + } + + for (headerExtension in description.allTags("rtp-hdrext", "urn:xmpp:jingle:apps:rtp:rtp-hdrext:0")) { + mediaAttributes.push(new Attribute("extmap", headerExtension.attr.get("id") + " " + headerExtension.attr.get("uri"))); + } + + if (description.getChild("extmap-allow-mixed", "urn:xmpp:jingle:apps:rtp:rtp-hdrext:0") != null) { + mediaAttributes.push(new Attribute("extmap-allow-mixed", "")); + } + + for (sourceGroup in description.allTags("ssrc-group", "urn:xmpp:jingle:apps:rtp:ssma:0")) { + mediaAttributes.push(new Attribute("ssrc-group", sourceGroup.attr.get("semantics") + " " + sourceGroup.allTags("source").map((el) -> el.attr.get("ssrc")).join(" "))); + } + + for (source in description.allTags("source", "urn:xmpp:jingle:apps:rtp:ssma:0")) { + for (parameter in source.allTags("parameter")) { + mediaAttributes.push(new Attribute("ssrc", source.attr.get("ssrc") + " " + parameter.attr.get("name") + ":" + parameter.attr.get("value"))); + } + } + + mediaAttributes.push(new Attribute("mid", mid)); + + switch(content.attr.get("senders")) { + case "none": + mediaAttributes.push(new Attribute("inactive", "")); + case "initiator": + if (initiator) { + mediaAttributes.push(new Attribute("sendonly", "")); + } else { + mediaAttributes.push(new Attribute("recvonly", "")); + } + case "responder": + if (initiator) { + mediaAttributes.push(new Attribute("recvonly", "")); + } else { + mediaAttributes.push(new Attribute("sendonly", "")); + } + default: + mediaAttributes.push(new Attribute("sendrecv", "")); + } + if (hasGroup || description.getChild("rtcp-mux") != null) { + mediaAttributes.push(new Attribute("rtcp-mux", "")); + } + + if (description.getChild("ice-lite") != null) { + mediaAttributes.push(new Attribute("ice-lite", "")); + } + + mediaAttributes.push(new Attribute("rtcp", "9 IN IP4 0.0.0.0")); + return new Media( + mid, + description == null ? "" : description.attr.get("media"), + "IN IP4 0.0.0.0", + "9", + "UDP/TLS/RTP/SAVPF", + mediaAttributes, + mediaFormats + ); + } + + public function toSdp() { + return + "m=" + media + " " + port + " " + protocol + " " + formats.join(" ") + "\r\n" + + "c=" + connectionData + "\r\n" + + attributes.map((attr) -> attr.toSdp()).join(""); + } + + public function contentElement(initiator: Bool) { + final attrs: DynamicAccess<String> = { xmlns: "urn:xmpp:jingle:1", creator: "initiator", name: mid }; + if (attributes.exists((attr) -> attr.key == "inactive")) { + attrs.set("senders", "none"); + } else if (attributes.exists((attr) -> attr.key == "sendonly")) { + attrs.set("senders", initiator ? "initiator" : "responder"); + } else if (attributes.exists((attr) -> attr.key == "recvonly")) { + attrs.set("senders", initiator ? "responder" : "initiator"); + } + return new Stanza("content", attrs); + } + + public function toElement(sessionAttributes: Array<Attribute>, initiator: Bool) { + final content = contentElement(initiator); + final description = content.tag("description", { xmlns: "urn:xmpp:jingle:apps:rtp:1", media: media }); + final fbs = attributes.filter((attr) -> attr.key == "rtcp-fb").map((fb) -> { + final segments = fb.value.split(" "); + return { id: segments[0], el: if (segments[1] == "trr-int") { + new Stanza("rtcp-fb-trr-int", { xmlns: "urn:xmpp:jingle:apps:rtp:rtcp-fb:0", value: segments[2] }); + } else { + var fbattrs: DynamicAccess<String> = { xmlns: "urn:xmpp:jingle:apps:rtp:rtcp-fb:0", type: segments[1] }; + if (segments.length >= 3) fbattrs.set("subtype", segments[2]); + new Stanza("rtcp-fb", fbattrs); + } }; + }); + final ssrc: Map<String, Array<Stanza>> = []; + final fmtp: Map<String, Array<Stanza>> = []; + for (attr in attributes) { + if (attr.key == "fmtp") { + final pos = attr.value.indexOf(" "); + if (pos < 0) continue; + fmtp.set(attr.value.substr(0, pos), attr.value.substr(pos+1).split(";").map((param) -> { + final eqPos = param.indexOf("="); + final attrs: DynamicAccess<String> = { value: eqPos > 0 ? param.substr(eqPos + 1) : param }; + if (eqPos > 0) attrs.set("name", param.substr(0, eqPos)); + return new Stanza("parameter", attrs); + })); + } else if (attr.key == "ssrc") { + final pos = attr.value.indexOf(" "); + if (pos < 0) continue; + final id = attr.value.substr(0, pos); + if (ssrc.get(id) == null) ssrc.set(id, []); + final param = attr.value.substr(pos + 1); + final colonPos = param.indexOf(":"); + final attrs: DynamicAccess<String> = { name: colonPos > 0 ? param.substr(0, colonPos) : param }; + if (colonPos > 0) attrs.set("value", param.substr(colonPos + 1)); + ssrc.get(id).push(new Stanza("parameter", attrs)); + } else if (attr.key == "extmap") { + final pos = attr.value.indexOf(" "); + if (pos < 0) continue; + description.tag("rtp-hdrext", { xmlns: "urn:xmpp:jingle:apps:rtp:rtp-hdrext:0", id: attr.value.substr(0, pos), uri: attr.value.substr(pos + 1)}).up(); + } else if (attr.key == "ssrc-group") { + final segments = attr.value.split(" "); + if (segments.length < 2) continue; + final group = description.tag("ssrc-group", { xmlns: "urn:xmpp:jingle:apps:rtp:ssma:0", semantics: segments[0] }); + for (seg in segments.slice(1)) { + group.tag("source", { ssrc: seg }).up(); + } + group.up(); + } + } + description.addChildren(fbs.filter((fb) -> fb.id == "*").map((fb) -> fb.el)); + description.addChildren(attributes.filter((attr) -> attr.key == "rtpmap").map((rtpmap) -> { + final pos = rtpmap.value.indexOf(" "); + if (pos < 0) throw "invalid rtpmap"; + final id = rtpmap.value.substr(0, pos); + final segments = rtpmap.value.substr(pos+1).split("/"); + final attrs: DynamicAccess<String> = { id: id }; + if (segments.length > 0) attrs.set("name", segments[0]); + if (segments.length > 1) attrs.set("clockrate", segments[1]); + if (segments.length > 2 && segments[2] != "" && segments[2] != "1") attrs.set("channels", segments[2]); + return new Stanza("payload-type", attrs) + .addChildren(fbs.filter((fb) -> fb.id == id).map((fb) -> fb.el)) + .addChildren(fmtp.get(id) == null ? [] : fmtp.get(id)); + })); + if (attributes.exists((attr) -> attr.key == "extmap-allow-mixed") || sessionAttributes.exists((attr) -> attr.key == "extmap-allow-mixed")) { + description.tag("extmap-allow-mixed", { xmlns: "urn:xmpp:jingle:apps:rtp:rtp-hdrext:0" }).up(); + } + for (entry in ssrc.keyValueIterator()) { + final msid = attributes.find((attr) -> attr.key == "msid"); + // We have nowhere in jingle to put a media-level msid + // Chrome and libwebrtc require a media-level or a ssrc level one if rtx is in use + // Firefox generates only a media level one + // So copy to ssrc level if it is present to make Chrome happy + if (msid != null && !entry.value.exists((param) -> param.attr.get("name") == "msid")) { + entry.value.push(new Stanza("parameter", { name: "msid", value: msid.value })); + } + description.tag("source", { xmlns: "urn:xmpp:jingle:apps:rtp:ssma:0", ssrc: entry.key }) + .addChildren(entry.value).up(); + } + if (attributes.exists((attr) -> attr.key == "rtcp-mux")) { + description.tag("rtcp-mux").up(); + } + if (attributes.exists((attr) -> attr.key == "ice-lite")) { + description.tag("ice-lite").up(); + } + description.up(); + content.addChild(toTransportElement(sessionAttributes)).up(); + return content; + } + + public function toTransportElement(sessionAttributes: Array<Attribute>) { + final transportAttr: DynamicAccess<String> = { xmlns: "urn:xmpp:jingle:transports:ice-udp:1" }; + final ufrag = attributes.find((attr) -> attr.key == "ice-ufrag"); + if (ufrag != null) transportAttr.set("ufrag", ufrag.value); + final pwd = attributes.find((attr) -> attr.key == "ice-pwd"); + if (pwd != null) transportAttr.set("pwd", pwd.value); + final transport = new Stanza("transport", transportAttr); + final fingerprint = (attributes.concat(sessionAttributes)).find((attr) -> attr.key == "fingerprint"); + final setup = (attributes.concat(sessionAttributes)).find((attr) -> attr.key == "setup"); + if (fingerprint != null && setup != null && fingerprint.value.indexOf(" ") > 0) { + final pos = fingerprint.value.indexOf(" "); + transport.textTag("fingerprint", fingerprint.value.substr(pos + 1), { xmlns: "urn:xmpp:jingle:apps:dtls:0", hash: fingerprint.value.substr(0, pos), setup: setup.value }); + } + transport.addChildren(attributes.filter((attr) -> attr.key == "candidate").map((attr) -> IceCandidate.parse(attr.value, mid, ufrag.value).toElement())); + transport.up(); + return transport; + } +} + +class IceCandidate { + public var sdpMid (default, null): String; + public var ufrag (default, null): Null<String>; + public var foundation (default, null): String; + public var component (default, null): String; + public var transport (default, null): String; + public var priority (default, null): String; + public var connectionAddress (default, null): String; + public var port (default, null): String; + public var parameters (default, null): Map<String, String>; + + public function new(sdpMid: String, ufrag: Null<String>, foundation: String, component: String, transport: String, priority: String, connectionAddress: String, port: String, parameters: Map<String, String>) { + this.sdpMid = sdpMid; + this.ufrag = ufrag; + this.foundation = foundation; + this.component = component; + this.transport = transport; + this.priority = priority; + this.connectionAddress = connectionAddress; + this.port = port; + this.parameters = parameters; + } + + public static function fromElement(candidate: Stanza, sdpMid: String, ufrag: Null<String>) { + final parameters: Map<String, String> = []; + if (candidate.attr.get("type") != null) parameters.set("typ", candidate.attr.get("type")); + if (candidate.attr.get("rel-addr") != null) parameters.set("raddr", candidate.attr.get("rel-addr")); + if (candidate.attr.get("rel-port") != null) parameters.set("rport", candidate.attr.get("rel-port")); + if (candidate.attr.get("generation") != null) parameters.set("generation", candidate.attr.get("generation")); + if (candidate.attr.get("tcptype") != null) parameters.set("tcptype", candidate.attr.get("tcptype")); + if (ufrag != null) parameters.set("ufrag", ufrag); + return new IceCandidate( + sdpMid, + ufrag, + candidate.attr.get("foundation"), + candidate.attr.get("component"), + candidate.attr.get("protocol").toLowerCase(), + candidate.attr.get("priority"), + candidate.attr.get("ip"), + candidate.attr.get("port"), + parameters + ); + } + + public static function fromTransport(transport: Stanza, sdpMid: String) { + return transport.allTags("candidate").map((el) -> fromElement(el, sdpMid, transport.attr.get("ufrag"))); + } + + public static function fromStanza(iq: Stanza) { + final jingle = iq.getChild("jingle", "urn:xmpp:jingle:1"); + return jingle.allTags("content").flatMap((content) -> { + final transport = content.getChild("transport", "urn:xmpp:jingle:transports:ice-udp:1"); + return fromTransport(transport, content.attr.get("name")); + }); + } + + public static function parse(input: String, sdpMid: String, ufrag: Null<String>) { + if (input.substr(0, 10) == "candidate:") { + input = input.substr(11); + } + final segments = input.split(" "); + final paramSegs = segments.slice(6); + final paramLength = Std.int(paramSegs.length / 2); + final parameters: Map<String, String> = []; + for (i in 0...paramLength) { + parameters.set(paramSegs[i*2], paramSegs[(i*2)+1]); + } + if (ufrag != null) parameters.set("ufrag", ufrag); + return new IceCandidate( + sdpMid, + ufrag, + segments[0], + segments[1], + segments[2], + segments[3], + segments[4], + segments[5], + parameters + ); + } + + public function toElement() { + final attrs: DynamicAccess<String> = { + xmlns: parameters.get("tcptype") == null ? "urn:xmpp:jingle:transports:ice-udp:1" : "urn:xmpp:jingle:transports:ice:0", + foundation: foundation, + component: component, + protocol: transport.toLowerCase(), + priority: priority, + ip: connectionAddress, + port: port + }; + if (parameters.get("typ") != null) attrs.set("type", parameters.get("typ")); + if (parameters.get("raddr") != null) attrs.set("rel-addr", parameters.get("raddr")); + if (parameters.get("rport") != null) attrs.set("rel-port", parameters.get("rport")); + if (parameters.get("generation") != null) attrs.set("generation", parameters.get("generation")); + if (parameters.get("tcptype") != null) attrs.set("tcptype", parameters.get("tcptype")); + return new Stanza("candidate", attrs); + } + + public function toSdp() { + var result = "candidate:" + + foundation + " " + + component + " " + + transport + " " + + priority + " " + + connectionAddress + " " + + port; + for (entry in parameters.keyValueIterator()) { + result += " " + entry.key + " " + entry.value; + } + return result; + } +} + +class Attribute { + public var key (default, null): String; + public var value (default, null): String; + + public function new(key: String, value: String) { + this.key = key; + this.value = value; + } + + public static function parse(input: String) { + final pos = input.indexOf(":"); + if (pos < 0) { + return new Attribute(input, ""); + } else { + return new Attribute(input.substr(0, pos), input.substr(pos+1)); + } + } + + public function toSdp() { + return "a=" + key + (value == null || value == "" ? "" : ":" + value) + "\r\n"; + } + + public function toString() { + return toSdp(); + } +}