git » sdk » commit a109683

Initial work on Jingle and SDP parsing and generation

author Stephen Paul Weber
2023-09-27 18:24:23 UTC
committer Stephen Paul Weber
2023-09-27 18:26:43 UTC
parent baa6311d282eca78370218fd103f68dc7e41e4d7

Initial work on Jingle and SDP parsing and generation

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();
+	}
+}