plugin XEP-0166: support all content actions + fixes:

- all content actions are now handled
- XEP-0167: the new contents of an offered SDP are added with a single content-add, the
  contents sharing a single transport are bundled (XEP-0338)
- XEP-0167: the generated SDP declares the BUNDLE group of its own contents
- XEP-0176: one transport-info IQ per content. ICE data are keyed by content names.
- fix session-level ICE attributes and fingerprint
This commit is contained in:
Goffi 2026-10-06 19:27:53 +02:00
commit ab26a56c1d
15 changed files with 1804 additions and 261 deletions

View file

@ -119,7 +119,7 @@ class RemoteControl(BaseApplicationHandler):
except KeyError: except KeyError:
raise exceptions.DataError('"call_data" must be set in "extra".') raise exceptions.DataError('"call_data" must be set in "extra".')
metadata = self._rtp.parse_call_data(call_data) metadata = self._rtp.parse_call_data(call_data, self._j.ROLE_INITIATOR)
try: try:
application_data = call_data["application"] application_data = call_data["application"]
except KeyError: except KeyError:

View file

@ -18,7 +18,17 @@
import time import time
from typing import Any, Callable, Dict, Final, List, Optional, Tuple from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Final,
List,
Optional,
Tuple,
cast,
)
import uuid import uuid
from twisted.internet import defer from twisted.internet import defer
@ -39,10 +49,16 @@ from libervia.backend.core.log import getLogger
from libervia.backend.tools import xml_tools from libervia.backend.tools import xml_tools
from libervia.backend.tools import utils from libervia.backend.tools import utils
if TYPE_CHECKING:
from libervia.backend.core.main import LiberviaBackend
from .models import ( from .models import (
ApplicationData, ApplicationData,
BaseApplicationHandler, BaseApplicationHandler,
BaseTransportHandler, BaseTransportHandler,
ContentChange,
ContentChangeAction,
ContentChangeItem,
ContentData, ContentData,
TransportData, TransportData,
) )
@ -96,6 +112,7 @@ class XEP_0166:
A_SESSION_TERMINATE: Final = "session-terminate" A_SESSION_TERMINATE: Final = "session-terminate"
A_SESSION_INFO: Final = "session-info" A_SESSION_INFO: Final = "session-info"
A_CONTENT_ADD: Final = "content-add" A_CONTENT_ADD: Final = "content-add"
A_CONTENT_ACCEPT: Final = "content-accept"
A_CONTENT_MODIFY: Final = "content-modify" A_CONTENT_MODIFY: Final = "content-modify"
A_CONTENT_REJECT: Final = "content-reject" A_CONTENT_REJECT: Final = "content-reject"
A_CONTENT_REMOVE: Final = "content-remove" A_CONTENT_REMOVE: Final = "content-remove"
@ -120,7 +137,7 @@ class XEP_0166:
#: cleaning operations #: cleaning operations
A_DESTROY: Final = "destroy" A_DESTROY: Final = "destroy"
def __init__(self, host): def __init__(self, host: "LiberviaBackend"):
log.info(_("plugin Jingle initialization")) log.info(_("plugin Jingle initialization"))
self.host = host self.host = host
self._applications = {} # key: namespace, value: application data self._applications = {} # key: namespace, value: application data
@ -139,6 +156,9 @@ class XEP_0166:
async_=True, async_=True,
) )
# args: serialised ContentChange payload, profile
host.bridge.add_signal("jingle_content_change", ".plugin", signature="ss")
def profile_connected(self, client): def profile_connected(self, client):
client.jingle_sessions = {} # key = sid, value = session_data client.jingle_sessions = {} # key = sid, value = session_data
@ -829,6 +849,14 @@ class XEP_0166:
await self.on_session_info(client, request, jingle_elt, session) await self.on_session_info(client, request, jingle_elt, session)
elif action == XEP_0166.A_CONTENT_ADD: elif action == XEP_0166.A_CONTENT_ADD:
await self.on_content_add(client, request, jingle_elt, session) await self.on_content_add(client, request, jingle_elt, session)
elif action == XEP_0166.A_CONTENT_MODIFY:
await self.on_content_modify(client, request, jingle_elt, session)
elif action == XEP_0166.A_CONTENT_ACCEPT:
await self.on_content_accept(client, request, jingle_elt, session)
elif action == XEP_0166.A_CONTENT_REJECT:
await self.on_content_reject(client, request, jingle_elt, session)
elif action == XEP_0166.A_CONTENT_REMOVE:
await self.on_content_remove(client, request, jingle_elt, session)
elif action == XEP_0166.A_TRANSPORT_INFO: elif action == XEP_0166.A_TRANSPORT_INFO:
self.on_transport_info(client, request, jingle_elt, session) self.on_transport_info(client, request, jingle_elt, session)
elif action == XEP_0166.A_TRANSPORT_REPLACE: elif action == XEP_0166.A_TRANSPORT_REPLACE:
@ -986,6 +1014,75 @@ class XEP_0166:
""" """
return elt return elt
def _emit_content_change(
self,
client: SatXMPPEntity,
action: str,
session: dict,
content_names: list[str] | None = None,
) -> None:
"""Emit the ``jingle_content_change`` signal for a content change
The "get_content_change_payload" method from application handler will be
used for each content of the stanza, so that the frontend can apply all
the changes in a single WebRTC negotiation.
@param content_names: names of the contents of the current stanza; by default, all
the contents of the session pending the action are used
"""
content_names_key = {
self.A_CONTENT_ADD: "added_content_names",
self.A_CONTENT_MODIFY: "modified_content_names",
self.A_CONTENT_ACCEPT: "accepted_content_names",
self.A_CONTENT_REMOVE: "removed_content_names",
self.A_CONTENT_REJECT: "rejected_content_names",
}[action]
if content_names is None:
content_names = session.get(content_names_key) or []
changes = []
for content_name in content_names:
if content_name not in session["contents"]:
log.warning(
f"unknown content name {content_name!r} in session {session['id']}"
)
continue
content_data = session["contents"][content_name]
try:
payload = content_data["application"].handler.get_content_change_payload(
client, action, session, content_name
)
except Exception:
log.exception(
f"can't get the payload of content {content_name!r} "
f"in session {session['id']}"
)
continue
changes.append(
ContentChangeItem(
content=content_name,
senders=content_data["senders"],
payload=payload,
)
)
if not changes:
return
info = ContentChange(
session_id=session["id"],
action=cast(ContentChangeAction, action),
changes=changes,
)
if not client.is_component:
self.host.bridge.jingle_content_change(
info.model_dump_json(exclude_none=True), client.profile
)
self.host.trigger.point(
"XEP-0166_content_change",
client,
action,
session,
[change.content for change in changes],
triggers_no_cancel=True,
)
async def _call_plugins( async def _call_plugins(
self, self,
client: SatXMPPEntity, client: SatXMPPEntity,
@ -1157,6 +1254,13 @@ class XEP_0166:
try: try:
for content_name, content_data in session["contents"].items(): for content_name, content_data in session["contents"].items():
# contents not from the initial offer are not part of the session-accept
# (they are negotiated with a content-add/accept)
if (
content_data.get("added_by_peer")
or content_data.get("creator") != XEP_0166.ROLE_INITIATOR
):
continue
content_elt = jingle_elt.addElement("content") content_elt = jingle_elt.addElement("content")
content_elt["creator"] = XEP_0166.ROLE_INITIATOR content_elt["creator"] = XEP_0166.ROLE_INITIATOR
content_elt["name"] = content_name content_elt["name"] = content_name
@ -1194,17 +1298,28 @@ class XEP_0166:
app_method_name=None, app_method_name=None,
elements=False, elements=False,
) )
self.host.trigger.point(
"XEP-0166_session_accept",
client,
session,
jingle_elt,
triggers_no_cancel=True,
)
session.pop("jingle_elt") session.pop("jingle_elt")
await iq_elt.send() await iq_elt.send()
except Exception as e:
await self._jingle_error_cb(e, session, jingle_elt, client)
return
session["state"] = XEP_0166.STATE_ACTIVE session["state"] = XEP_0166.STATE_ACTIVE
try:
await self._call_plugins( await self._call_plugins(
client, XEP_0166.A_ACCEPTED_ACK, session, elements=False client, XEP_0166.A_ACCEPTED_ACK, session, elements=False
) )
except Exception: except Exception:
log.exception("Error while sending jingle <iq/> stanza") # the session-accept has been sent, an error in the ack handling must not kill
self.delete_session(client, session["id"]) # an active session
log.exception("Error in the accepted-ack handling")
def get_reason_elt(self, parent_elt: domish.Element) -> domish.Element: def get_reason_elt(self, parent_elt: domish.Element) -> domish.Element:
"""Find a <reason> element in parent_elt """Find a <reason> element in parent_elt
@ -1262,6 +1377,10 @@ class XEP_0166:
force_element=reason_elt, force_element=reason_elt,
) )
await self.host.trigger.async_point(
"XEP-0166_session_terminate", client, session, reason_elt
)
self.delete_session(client, session["id"]) self.delete_session(client, session["id"])
await client.a_send(xmlstream.toResponse(request, "result")) await client.a_send(xmlstream.toResponse(request, "result"))
@ -1346,16 +1465,18 @@ class XEP_0166:
) -> None: ) -> None:
"""Called on content-add action """Called on content-add action
The "jingle_request_confirmation" method of each application will be called The contents are stored in the session, pending a content-accept.
(or self.jingle_request_confirmation_default if the former doesn't exist).
The session is only accepted if all application are confirmed.
The application must manage itself multiple contents scenari (e.g. audio/video).
@param client: %(doc_client)s @param client: %(doc_client)s
@param request(domish.Element): full request @param request(domish.Element): full request
@param jingle_elt(domish.Element): <jingle> element @param jingle_elt(domish.Element): <jingle> element
@param session(dict): session data @param session(dict): session data
""" """
return # the received contents are created by the peer (the session's other role)
creator = (
XEP_0166.ROLE_RESPONDER
if session["role"] == XEP_0166.ROLE_INITIATOR
else XEP_0166.ROLE_INITIATOR
)
try: try:
contents_dict = self._parse_elements( contents_dict = self._parse_elements(
jingle_elt, jingle_elt,
@ -1363,7 +1484,7 @@ class XEP_0166:
request, request,
client, client,
True, True,
XEP_0166.ROLE_INITIATOR, creator,
) )
except exceptions.CancelError: except exceptions.CancelError:
return return
@ -1374,20 +1495,194 @@ class XEP_0166:
return return
session["contents_new"] = contents_dict session["contents_new"] = contents_dict
# a former content-add may still be pending an accept
session.setdefault("added_content_names", []).extend(contents_dict)
# at this point we can send the <iq/> result to confirm reception of the request # at this point we can send the <iq/> result to confirm reception of the request
client.send(xmlstream.toResponse(request, "result")) client.send(xmlstream.toResponse(request, "result"))
assert "jingle_elt" not in session # the session may already have a jingle_elt (e.g. the session-accept one)
session["jingle_elt"] = jingle_elt session["jingle_elt"] = jingle_elt
await self._call_plugins(client, XEP_0166.A_CONTENT_ADD, session, delete=False) await self._call_plugins(client, XEP_0166.A_CONTENT_ADD, session, delete=False)
self._emit_content_change(
client, XEP_0166.A_CONTENT_ADD, session, list(contents_dict)
)
async def on_content_modify(
self,
client: SatXMPPEntity,
request: domish.Element,
jingle_elt: domish.Element,
session: dict[str, Any],
) -> None:
"""Called on content-modify action
The "senders" attribute of the modified contents is updated and the frontend is
notified. An application can refuse the change from its "jingle_handler"
(reverting the "senders" attribute and sending a contrary content-modify).
@param client: the client instance
@param request: full request
@param jingle_elt: <jingle> element
@param session: session data
"""
try:
contents_dict = self._parse_elements(
jingle_elt,
session,
request,
client,
False,
XEP_0166.ROLE_INITIATOR,
with_application=False,
with_transport=False,
)
except exceptions.CancelError:
return
# update the senders attribute of the modified content(s)
applied_names: list[str] = []
for content_elt in jingle_elt.elements(NS_JINGLE, "content"):
name = content_elt["name"]
content_data = contents_dict[name]
content_data["senders"] = content_elt.attributes.get("senders", "both")
applied_names.append(name)
# acknowledge the request (per spec, no content-accept for content-modify)
client.send(xmlstream.toResponse(request, "result"))
if not applied_names:
return
session["modified_content_names"] = applied_names
session["jingle_elt"] = jingle_elt
await self._call_plugins(
client, XEP_0166.A_CONTENT_MODIFY, session, delete=False, elements=False
)
self._emit_content_change(client, XEP_0166.A_CONTENT_MODIFY, session)
async def on_content_accept(
self,
client: SatXMPPEntity,
request: domish.Element,
jingle_elt: domish.Element,
session: dict[str, Any],
) -> None:
"""Called on content-accept action (the peer accepts a content we added)
The peer answer (description + transport) is stored in the session, and the
frontend is notified so it can set the remote description.
@param client: the client instance
@param request: full request
@param jingle_elt: <jingle> element
@param session: session data
"""
try:
self._parse_elements(
jingle_elt,
session,
request,
client,
False,
XEP_0166.ROLE_RESPONDER,
with_application=True,
with_transport=True,
)
except exceptions.CancelError:
return
session["accepted_content_names"] = [
elt["name"] for elt in jingle_elt.elements(NS_JINGLE, "content")
]
# acknowledge the request
client.send(xmlstream.toResponse(request, "result"))
self._emit_content_change(client, XEP_0166.A_CONTENT_ACCEPT, session)
async def _on_content_removal(
self,
client: SatXMPPEntity,
request: domish.Element,
jingle_elt: domish.Element,
session: dict[str, Any],
action: str,
) -> None:
"""Handle a content-reject or content-remove action
@param client: the client entity.
@param request: full <iq> request.
@param jingle_elt: the <jingle> element.
@param session: the session data.
@param action: the action (content-reject or content-remove).
"""
log.debug(f"{action} received for session {session['id']}")
try:
self._parse_elements(
jingle_elt,
session,
request,
client,
False,
XEP_0166.ROLE_INITIATOR,
with_application=False,
with_transport=False,
)
except exceptions.CancelError:
return
prefix = "removed" if action == self.A_CONTENT_REMOVE else "rejected"
names_key = f"{prefix}_content_names"
session[names_key] = [
elt["name"] for elt in jingle_elt.elements(NS_JINGLE, "content")
]
# acknowledge the request
client.send(xmlstream.toResponse(request, "result"))
self._emit_content_change(client, action, session)
for content_name in session[names_key]:
del session["contents"][content_name]
async def on_content_reject(
self,
client: SatXMPPEntity,
request: domish.Element,
jingle_elt: domish.Element,
session: dict[str, Any],
) -> None:
"""Called on content-reject action (the peer rejects a content we added)
@param client: the client entity.
@param request: full <iq> request.
@param jingle_elt: the <jingle> element.
@param session: the session data.
"""
await self._on_content_removal(
client, request, jingle_elt, session, self.A_CONTENT_REJECT
)
async def on_content_remove(
self,
client: SatXMPPEntity,
request: domish.Element,
jingle_elt: domish.Element,
session: dict[str, Any],
) -> None:
"""Called on content-remove action (the creator removes one of its contents)
@param client: the client entity.
@param request: full <iq> request.
@param jingle_elt: the <jingle> element.
@param session: the session data.
"""
await self._on_content_removal(
client, request, jingle_elt, session, self.A_CONTENT_REMOVE
)
async def on_transport_replace(self, client, request, jingle_elt, session): async def on_transport_replace(self, client, request, jingle_elt, session):
"""A transport change is requested """A transport change is requested
The request is parsed, and jingle_handler is called on concerned transport plugin(s) The request is parsed, and jingle_handler is called on concerned transport plugin(s)
@param client: %(doc_client)s @param client: the client instance
@param request(domish.Element): full <iq> request @param request(domish.Element): full <iq> request
@param jingle_elt(domish.Element): the <jingle> element @param jingle_elt(domish.Element): the <jingle> element
@param session(dict): session data @param session(dict): session data
@ -1540,7 +1835,7 @@ class XEP_0166:
@param jingle_elt: the <jingle> element @param jingle_elt: the <jingle> element
@param session: session data @param session: session data
""" """
log.debug(f"Jingle session {session['id']} has been accepted") log.debug(f"received a transport-info for Jingle session {session['id']}")
try: try:
parsed_contents = self._parse_elements( parsed_contents = self._parse_elements(

View file

@ -19,8 +19,9 @@
import abc import abc
from dataclasses import dataclass from dataclasses import dataclass
from typing import Awaitable, Callable, Union from typing import Awaitable, Callable, Literal, Union
from pydantic import BaseModel, ConfigDict, Field, SerializeAsAny
from twisted.internet import defer from twisted.internet import defer
from twisted.words.xish import domish from twisted.words.xish import domish
@ -28,6 +29,50 @@ from libervia.backend.core import exceptions
from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.core_types import SatXMPPEntity
from libervia.backend.core.i18n import _ from libervia.backend.core.i18n import _
ContentChangeAction = Literal[
"content-add",
"content-modify",
"content-accept",
"content-remove",
"content-reject",
]
class ContentChangePayload(BaseModel):
"""Base payload of the ``jingle_content_change`` signal, to be subclassed"""
model_config = ConfigDict(extra="allow")
class ContentChangeItem(BaseModel):
"""One changed content of the ``jingle_content_change`` signal"""
content: str = Field(description="Name of the concerned content")
senders: Literal["initiator", "responder", "both", "none"] = Field(
description="Senders value of the content"
)
# "SerializeAsAny" allows the use of subclasses (cf.
# https://stackoverflow.com/q/77049825, thanks!))
payload: SerializeAsAny[ContentChangePayload] = Field(
default_factory=ContentChangePayload,
description=(
"Application-specific payload, provided by the application handler "
"(``get_content_change_payload``)"
),
)
class ContentChange(BaseModel):
"""Payload of the ``jingle_content_change`` signal."""
session_id: str = Field(description="ID of the Jingle session")
action: ContentChangeAction = Field(
description="Jingle action that triggered the change"
)
changes: list[ContentChangeItem] = Field(
description="The changed contents of the stanza"
)
class BaseApplicationHandler(abc.ABC): class BaseApplicationHandler(abc.ABC):
@ -118,6 +163,16 @@ class BaseApplicationHandler(abc.ABC):
""" """
pass pass
def get_content_change_payload(
self, client: SatXMPPEntity, action: str, session: dict, content_name: str
) -> ContentChangePayload:
"""Build the application-specific payload of the content change signal
@return: payload to store in the ``payload`` field of the
``jingle_content_change`` signal
"""
return ContentChangePayload()
@abc.abstractmethod @abc.abstractmethod
def jingle_terminate( def jingle_terminate(
self, self,

View file

@ -16,7 +16,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Optional from typing import Literal, Optional
from twisted.internet import reactor from twisted.internet import reactor
from twisted.internet import defer from twisted.internet import defer
@ -37,8 +37,8 @@ from libervia.backend.tools.common import data_format
from . import mapping from . import mapping
from ..plugin_xep_0166 import BaseApplicationHandler from ..plugin_xep_0166 import BaseApplicationHandler
from .models import RTPContentChangePayload
from .constants import ( from .constants import (
NS_AV_CONFERENCES,
NS_JINGLE_RTP, NS_JINGLE_RTP,
NS_JINGLE_RTP_AUDIO, NS_JINGLE_RTP_AUDIO,
NS_JINGLE_RTP_INFO, NS_JINGLE_RTP_INFO,
@ -81,8 +81,6 @@ class XEP_0167(BaseApplicationHandler):
def __init__(self, host): def __init__(self, host):
log.info(f'Plugin "{PLUGIN_INFO[C.PI_NAME]}" initialization') log.info(f'Plugin "{PLUGIN_INFO[C.PI_NAME]}" initialization')
self.host = host self.host = host
# FIXME: to be removed once host is accessible from global var
mapping.host = host
self._j = host.plugins["XEP-0166"] self._j = host.plugins["XEP-0166"]
self._j.register_application(NS_JINGLE_RTP, self) self._j.register_application(NS_JINGLE_RTP, self)
host.register_namespace("jingle-rtp", NS_JINGLE_RTP) host.register_namespace("jingle-rtp", NS_JINGLE_RTP)
@ -110,6 +108,30 @@ class XEP_0167(BaseApplicationHandler):
method=self._call_answer_sdp, method=self._call_answer_sdp,
async_=False, async_=False,
) )
host.bridge.add_method(
"call_content_accept",
".plugin",
in_sign="ssss",
out_sign="",
method=self._call_content_accept,
async_=False,
)
host.bridge.add_method(
"call_content_modify",
".plugin",
in_sign="ssss",
out_sign="",
method=self._call_content_modify,
async_=False,
)
host.bridge.add_method(
"call_content_add",
".plugin",
in_sign="sss",
out_sign="",
method=self._call_content_add,
async_=False,
)
host.bridge.add_method( host.bridge.add_method(
"call_info", "call_info",
".plugin", ".plugin",
@ -128,6 +150,8 @@ class XEP_0167(BaseApplicationHandler):
# args: session_id, serialised setup data (dict with keys "role" and "sdp"), # args: session_id, serialised setup data (dict with keys "role" and "sdp"),
# profile # profile
host.trigger.add("XEP-0166_content_change", self.on_content_change_trigger)
host.bridge.add_signal("call_setup", ".plugin", signature="sss") host.bridge.add_signal("call_setup", ".plugin", signature="sss")
# args: session_id, serialised update data, profile # args: session_id, serialised update data, profile
@ -142,6 +166,39 @@ class XEP_0167(BaseApplicationHandler):
def get_handler(self, client): def get_handler(self, client):
return XEP_0167_handler() return XEP_0167_handler()
def get_content_change_payload(
self, client: SatXMPPEntity, action: str, session: dict, content_name: str
) -> RTPContentChangePayload:
"""Return payload of the content change signal (media and SDP)"""
content_data = session["contents"][content_name]
application_data = content_data["application_data"]
payload = RTPContentChangePayload(media=application_data["media"])
if action == self._j.A_CONTENT_ADD:
# answer the content-add with the local description of the new media
payload.sdp = mapping.generate_content_add_sdp(
content_data, session, content_name
)
payload.origin = application_data.get("origin")
elif action == self._j.A_CONTENT_ACCEPT:
# parse the peer answer (description + transport), so that the peer m-line can
# be generated
content_data["application_data"]["peer_data"] = mapping.parse_description(
content_data["desc_elt"]
)
peer_ice_data = content_data["transport"].handler.parse_transport(
content_data["transport_elt"]
)
content_data["transport_data"]["peer_ice_data"] = peer_ice_data
payload.sdp = mapping.generate_content_add_sdp(
content_data, session, content_name
)
elif action == self._j.A_CONTENT_MODIFY:
# new direction to apply to the remote m-line (peer perspective)
payload.direction = mapping.senders_to_sdp(
content_data["senders"], session
).removeprefix("a=")
return payload
# bridge methods # bridge methods
def _call_start( def _call_start(
@ -168,12 +225,18 @@ class XEP_0167(BaseApplicationHandler):
self.call_update(client, session_id, data_format.deserialise(call_data_s)) self.call_update(client, session_id, data_format.deserialise(call_data_s))
) )
def parse_call_data(self, call_data: dict) -> dict: def parse_call_data(
"""Parse ``call_data`` and return corresponding contents end metadata""" self, call_data: dict, role: Literal["initiator", "responder"]
) -> dict:
"""Parse ``call_data`` and return corresponding contents and metadata
@param call_data: call data (see [call_start] for the expected structure)
@param role: role of the entity which created the SDP
"""
metadata = call_data.get("metadata") or {} metadata = call_data.get("metadata") or {}
if "sdp" in call_data: if "sdp" in call_data:
sdp_data = mapping.parse_sdp(call_data["sdp"], self._j.ROLE_INITIATOR) sdp_data = mapping.parse_sdp(call_data["sdp"], role)
to_delete = set() to_delete = set()
for media, data in sdp_data.items(): for media, data in sdp_data.items():
if media not in ("audio", "video", "application"): if media not in ("audio", "video", "application"):
@ -261,8 +324,8 @@ class XEP_0167(BaseApplicationHandler):
"""Initiate a call session with the given peer. """Initiate a call session with the given peer.
@param peer_jid: JID of the peer to initiate a call session with. @param peer_jid: JID of the peer to initiate a call session with.
@param call_data: Dictionary containing data for the call. Must include SDP information. @param call_data: Dictionary containing data for the call. Must include SDP
The dict can have the following keys: information. The dict can have the following keys:
- sdp (str): SDP data for the call. - sdp (str): SDP data for the call.
- metadata (dict): Additional metadata for the call (optional). - metadata (dict): Additional metadata for the call (optional).
Each media type ("audio" and "video") in the SDP should have: Each media type ("audio" and "video") in the SDP should have:
@ -285,7 +348,7 @@ class XEP_0167(BaseApplicationHandler):
"WebRTC is disabled when using Tor to avoid leaking IP address." "WebRTC is disabled when using Tor to avoid leaking IP address."
) )
metadata = self.parse_call_data(call_data) metadata = self.parse_call_data(call_data, self._j.ROLE_INITIATOR)
contents = self.get_contents(call_data, metadata) contents = self.get_contents(call_data, metadata)
if not contents: if not contents:
raise exceptions.DataError("no valid media data found: {call_data}") raise exceptions.DataError("no valid media data found: {call_data}")
@ -312,65 +375,82 @@ class XEP_0167(BaseApplicationHandler):
client: SatXMPPEntity, client: SatXMPPEntity,
session_id: str, session_id: str,
call_data: dict, call_data: dict,
) -> None: role: Literal["initiator", "responder"] | None = None,
"""Update a running call session. jingle_child_elts: list[domish.Element] | None = None,
) -> list[str]:
"""Add new contents to a running call session.
@param session_id: ID of the Jingle session to update. @param session_id: ID of the Jingle session to update.
@param call_data: Dictionary containing updated data for the call. Must include SDP information. @param call_data: offered data, must include an "sdp" key (see call_start).
The dict can have the following keys: @param role: role of the entity which created the SDP.
- sdp (str): SDP data for the call. @param jingle_child_elts: elements to add to the <jingle> element.
- metadata (dict): Additional metadata for the call (optional). @return: names of the contents added to the session.
Each media type ("audio" and "video") in the SDP should have:
- application_data (dict): Data about the media.
- fingerprint (str): Security fingerprint data (optional).
- id (str): Identifier for the media (optional).
- ice-candidates: ICE candidates for media transport.
- And other transport specific data.
@raises exceptions.DataError: If media data is invalid or duplicate content name
(mid) is found.
""" """
session = self._j.get_session(client, session_id) session = self._j.get_session(client, session_id)
try: if "sdp" not in call_data:
new_offer_sdp = call_data["sdp"]
except KeyError:
raise exceptions.DataError(f"New SDP offer is missing: {call_data}") raise exceptions.DataError(f"New SDP offer is missing: {call_data}")
metadata = self.parse_call_data(call_data) metadata = self.parse_call_data(call_data, role or session["role"])
contents = self.get_contents(call_data, metadata) contents = self.get_contents(call_data, metadata)
if not contents: if not contents:
raise exceptions.DataError("no valid media data found: {call_data}") raise exceptions.DataError(f"no valid media data found: {call_data}")
call_type = ( iq_elt = None
C.META_SUBTYPE_CALL_VIDEO jingle_elt = None
if "video" in call_data added: list[str] = []
else C.META_SUBTYPE_CALL_AUDIO
)
for content_args in contents: for content_args in contents:
content = content_args["app_kwargs"] name = content_args["name"]
content["app_ns"] = NS_JINGLE_RTP if name is None:
content["name"] = (content_args["name"],) raise exceptions.DataError(
content["transport_type"] = self._j.TRANSPORT_DATAGRAM f"a media is missing its ID (mid): {content_args}"
media = content["media"] )
media_data = content["media_data"].copy() if name in session["contents"]:
media_data["transport_data"] = content_args["transport_data"][ log.debug(f"content {name!r} already exists, skipping")
"local_ice_data" continue
] media = content_args["app_kwargs"]["media"]
desc_elt = mapping.build_description(media, media_data, {}) media_data = content_args["app_kwargs"]["media_data"]
iq_elt, __ = self._j.build_action( ice_data = content_args["transport_data"]["local_ice_data"]
desc_elt = mapping.build_description(media, media_data, session)
content_data = self._j.get_content_data(content_args)
transport = self._j.get_transport(client, content_args, content_data)
transport_elt = transport.handler.build_transport(ice_data)
iq_elt, context_elt = self._j.build_action(
client, client,
self._j.A_CONTENT_ADD, self._j.A_CONTENT_ADD,
session, session,
content_args["name"], name,
context_elt=desc_elt, context_elt=desc_elt,
iq_elt=iq_elt,
) )
content_data = self._j.get_content_data(content) content_elt = context_elt.parent
transport = self._j.get_transport(client, content, content_data) jingle_elt = content_elt.parent
transport_elt = transport.handler.build_transport( content_elt["senders"] = content_args.get("senders", "both")
media_data["transport_data"] content_elt.addChild(transport_elt)
session["contents"][name] = {
"application": content_data.application,
"application_data": {
"media": media,
"local_data": media_data,
},
"transport": transport,
"transport_data": content_args["transport_data"],
"creator": session["role"],
"senders": content_args.get("senders", "both"),
}
added.append(name)
if iq_elt is not None:
assert jingle_elt is not None
for child in jingle_child_elts or []:
jingle_elt.addChild(child)
self.host.trigger.point(
"XEP-0167_content_add_elt_built",
client,
session,
jingle_elt,
added,
triggers_no_cancel=True,
) )
iq_elt.jingle.content.addChild(transport_elt)
await iq_elt.send() await iq_elt.send()
return added
def _call_answer_sdp(self, session_id: str, answer_sdp: str, profile: str) -> None: def _call_answer_sdp(self, session_id: str, answer_sdp: str, profile: str) -> None:
client = self.host.get_client(profile) client = self.host.get_client(profile)
@ -383,6 +463,150 @@ class XEP_0167(BaseApplicationHandler):
) )
answer_sdp_d.callback(answer_sdp) answer_sdp_d.callback(answer_sdp)
def _call_content_accept(
self, session_id: str, content_name: str, mline_sdp: str, profile: str
) -> None:
"""Send a content-accept IQ to the peer (in response to a content-add)
The frontend answers the m-line offered by the peer, and sends it back. It is
converted to Jingle <description> and <transport> elements.
@param session_id: Jingle session ID
@param content_name: name of the content added by the peer
@param mline_sdp: SDP m-line of the frontend answer for the new media
@param profile: profile key
"""
client = self.host.get_client(profile)
session = self._j.get_session(client, session_id)
try:
pending_names = session["added_content_names"]
pending_names.remove(content_name)
except (KeyError, ValueError):
raise exceptions.NotFound(
f"Content {content_name!r} is not pending in session {session_id!r}"
)
if not pending_names:
del session["added_content_names"]
content_data = session["contents"][content_name]
media = content_data["application_data"]["media"]
# the answer is from our perspective, whatever our role is in the session
_, media_data = mapping.parse_mline(mline_sdp, session["role"])
local_data = media_data["application_data"]
local_ice_data = media_data["transport_data"]
# store the local data in the session (used for SDP generation)
content_data["application_data"]["local_data"] = local_data
content_data["transport_data"]["local_ice_data"] = local_ice_data
desc_elt = mapping.build_description(media, local_data, session)
transport_elt = content_data["transport"].handler.build_transport(local_ice_data)
iq_elt, jingle_elt = self._j._build_jingle_elt(
client, session, self._j.A_CONTENT_ACCEPT
)
content_elt = jingle_elt.addElement("content", self._j.namespace)
# the content has been created by the peer
content_elt["creator"] = content_data.get("creator", self._j.ROLE_INITIATOR)
content_elt["name"] = content_name
content_elt["senders"] = content_data["senders"]
content_elt.addChild(desc_elt)
content_elt.addChild(transport_elt)
client.send(iq_elt)
def _call_content_modify(
self, session_id: str, media: str, senders: str, profile: str
) -> None:
"""Send a content-modify IQ to the peer
Used when the frontend wants to also send media on a content added by the peer.
@param session_id: Jingle session ID
@param media: media type of the content to modify (e.g. "video")
@param senders: new senders value (e.g. "both")
@param profile: profile key
"""
client = self.host.get_client(profile)
session = self._j.get_session(client, session_id)
content_name: str | None = None
for name, content_data in session["contents"].items():
if content_data["application_data"].get("media") == media:
content_name = name
break
if content_name is None:
raise exceptions.NotFound(
f"No content with media {media!r} in session {session_id!r}"
)
content_data = session["contents"][content_name]
iq_elt, jingle_elt = self._j._build_jingle_elt(
client, session, self._j.A_CONTENT_MODIFY
)
content_elt = jingle_elt.addElement("content", self._j.namespace)
content_elt["creator"] = content_data.get("creator", self._j.ROLE_INITIATOR)
content_elt["name"] = content_name
content_elt["senders"] = senders
client.send(iq_elt)
def _call_content_add(self, session_id: str, mline_sdp: str, profile: str) -> None:
"""Send a content-add IQ to the peer (add a new media content to the session)
@param session_id: Jingle session ID
@param mline_sdp: SDP m-line of the new media (our offer)
@param profile: profile key
"""
client = self.host.get_client(profile)
session = self._j.get_session(client, session_id)
media, media_data = mapping.parse_mline(mline_sdp, session["role"])
local_data = media_data["application_data"]
local_ice_data = media_data["transport_data"]
# generate a unique content name (a content-add must not reuse an
# existing name, and the session entry must not be overwritten)
content_name = media
suffix = 2
while content_name in session["contents"]:
content_name = f"{media}_{suffix}"
suffix += 1
# The transport must be a registered one (not None): contents are iterated on
# plugin handler calls, where the transport handler is accessed
content_data = self._j.get_content_data(
{
"app_ns": NS_JINGLE_RTP,
"name": content_name,
"transport_data": {"local_ice_data": local_ice_data},
}
)
session["contents"][content_name] = content_data = {
"application": self._j.get_application(NS_JINGLE_RTP),
"application_data": {"media": media, "local_data": local_data},
"transport": self._j.get_transport(
client, {"transport_type": self._j.TRANSPORT_DATAGRAM}, content_data
),
"transport_data": {"local_ice_data": local_ice_data},
"creator": session["role"],
"senders": session["role"],
}
desc_elt = mapping.build_description(media, local_data, session)
transport_elt = content_data["transport"].handler.build_transport(local_ice_data)
iq_elt, jingle_elt = self._j._build_jingle_elt(
client, session, self._j.A_CONTENT_ADD
)
content_elt = jingle_elt.addElement("content", self._j.namespace)
content_elt["creator"] = session["role"]
content_elt["name"] = content_name
content_elt["senders"] = session["role"]
content_elt.addChild(desc_elt)
content_elt.addChild(transport_elt)
client.send(iq_elt)
def _call_end( def _call_end(
self, self,
session_id: str, session_id: str,
@ -406,7 +630,12 @@ class XEP_0167(BaseApplicationHandler):
@param data: optional extra data, may be used to indicate the reason to end the @param data: optional extra data, may be used to indicate the reason to end the
call call
""" """
session = self._j.get_session(client, session_id) try:
session = self._j.get_session(client, session_id)
except exceptions.NotFound:
# the session is already gone (e.g. duplicated call_end): nothing to do
log.warning(f"No session with SID {session_id} found, call_end ignored.")
return
await self._j.terminate(client, self._j.REASON_SUCCESS, session) await self._j.terminate(client, self._j.REASON_SUCCESS, session)
# jingle callbacks # jingle callbacks
@ -644,6 +873,9 @@ class XEP_0167(BaseApplicationHandler):
def propagate_data(self, session: dict, parsed_answer: dict) -> None: def propagate_data(self, session: dict, parsed_answer: dict) -> None:
"""Propagate local SDP data to other contents""" """Propagate local SDP data to other contents"""
for media in ("audio", "video", "application"): for media in ("audio", "video", "application"):
if media not in parsed_answer:
# the frontend may not answer all the contents
continue
for content in session["contents"].values(): for content in session["contents"].values():
try: try:
application_data = content["application_data"] application_data = content["application_data"]
@ -723,9 +955,14 @@ class XEP_0167(BaseApplicationHandler):
raise exceptions.ConflictError( raise exceptions.ConflictError(
f"There is already a {content_name!r} content." f"There is already a {content_name!r} content."
) )
# the content will be answered with a content-accept
content_data["added_by_peer"] = True
current_contents[content_name] = content_data current_contents[content_name] = content_data
application_data["media"] = desc_elt["media"] application_data["media"] = desc_elt["media"]
application_data["peer_data"] = mapping.parse_description(desc_elt) application_data["peer_data"] = mapping.parse_description(desc_elt)
elif action == self._j.A_CONTENT_MODIFY:
# the senders attribute has been updated in "on_content_modify"
pass
else: else:
log.warning(f"FIXME: unmanaged action {action}") log.warning(f"FIXME: unmanaged action {action}")
@ -740,6 +977,32 @@ class XEP_0167(BaseApplicationHandler):
) )
return desc_elt return desc_elt
def on_content_change_trigger(
self,
client: SatXMPPEntity,
action: str,
session: dict,
content_names: list[str],
) -> None:
"""Pass the peer answer SDP of the accepted contents to the session.
@param client: the client entity.
@param action: the content action (content-accept).
@param session: the session data. If the "content_accept_cb" entry is set, it is
called with the peer answer SDP of the accepted contents; a BUNDLE group is
added when several contents are accepted at once.
@param content_names: names of the accepted contents.
"""
if action != self._j.A_CONTENT_ACCEPT:
return
cb = session.get("content_accept_cb")
if cb is None:
return
sdp = mapping.generate_sdp_from_session(session, content_names=content_names)
result = cb(client, session, sdp, content_names)
if result is not None:
defer.ensureDeferred(result)
def jingle_session_info( def jingle_session_info(
self, self,
client: SatXMPPEntity, client: SatXMPPEntity,
@ -811,7 +1074,6 @@ class XEP_0167_handler(XMPPHandler):
disco.DiscoFeature(NS_JINGLE_RTP), disco.DiscoFeature(NS_JINGLE_RTP),
disco.DiscoFeature(NS_JINGLE_RTP_AUDIO), disco.DiscoFeature(NS_JINGLE_RTP_AUDIO),
disco.DiscoFeature(NS_JINGLE_RTP_VIDEO), disco.DiscoFeature(NS_JINGLE_RTP_VIDEO),
disco.DiscoFeature(NS_AV_CONFERENCES),
] ]
def getDiscoItems(self, requestor, target, nodeIdentifier=""): def getDiscoItems(self, requestor, target, nodeIdentifier=""):

View file

@ -17,10 +17,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64 import base64
from typing import Any, Dict, Optional from typing import Any, Dict, List, Literal, Optional, Tuple
from twisted.words.xish import domish from twisted.words.xish import domish
from libervia.backend import G
from libervia.backend.core.constants import Const as C from libervia.backend.core.constants import Const as C
from libervia.backend.core.log import getLogger from libervia.backend.core.log import getLogger
@ -28,11 +29,13 @@ from .constants import NS_JINGLE_RTP
log = getLogger(__name__) log = getLogger(__name__)
host = None
def senders_to_sdp(senders: str, session: dict) -> str: def senders_to_sdp(senders: str, session: dict) -> str:
"""Returns appropriate SDP attribute corresponding to Jingle senders attribute""" """Returns the SDP direction attribute corresponding to a Jingle senders attribute
@param senders: value of the Jingle "senders" attribute.
@param session: session data (the "role" key is used).
"""
if senders == "both": if senders == "both":
return "a=sendrecv" return "a=sendrecv"
elif senders == "none": elif senders == "none":
@ -43,7 +46,12 @@ def senders_to_sdp(senders: str, session: dict) -> str:
return "a=recvonly" return "a=recvonly"
def generate_sdp_from_session(session: dict, local: bool = False, port: int = 9) -> str: def generate_sdp_from_session(
session: dict,
local: bool = False,
port: int = 9,
content_names: list[str] | None = None,
) -> str:
"""Generate an SDP string from session data. """Generate an SDP string from session data.
@param session: A dictionary containing the session data. It should have the @param session: A dictionary containing the session data. It should have the
@ -73,10 +81,12 @@ def generate_sdp_from_session(session: dict, local: bool = False, port: int = 9)
otherwise for the peer entity. Generally the local SDP is received from frontends otherwise for the peer entity. Generally the local SDP is received from frontends
and not needed in backend, except for debugging purpose. and not needed in backend, except for debugging purpose.
@param port: The preferred port for communications. @param port: The preferred port for communications.
@param content_names: if set, only the contents with these names are included.
@return: The generated SDP string. @return: The generated SDP string.
""" """
contents = session["contents"] contents = session["contents"]
if content_names is not None:
contents = {name: contents[name] for name in content_names}
sdp_lines = ["v=0"] sdp_lines = ["v=0"]
# Add originator (o=) line after the version (v=) line # Add originator (o=) line after the version (v=) line
@ -97,18 +107,19 @@ def generate_sdp_from_session(session: dict, local: bool = False, port: int = 9)
sdp_lines.append("t=0 0") sdp_lines.append("t=0 0")
# stream direction # stream direction
all_senders = {c["senders"] for c in session["contents"].values()} all_senders = {c["senders"] for c in contents.values()}
# if we don't have a common senders for all contents, we set them at media level # if we don't have a common senders for all contents, we set them at media level
senders = all_senders.pop() if len(all_senders) == 1 else None senders = all_senders.pop() if len(all_senders) == 1 else None
sdp_lines.append("a=msid-semantic:WMS *") sdp_lines.append("a=msid-semantic:WMS *")
sdp_lines.append("a=ice-options:trickle") sdp_lines.append("a=ice-options:trickle")
host.trigger.point( G.host.trigger.point(
"XEP-0167_generate_sdp_session", "XEP-0167_generate_sdp_session",
session, session,
local, local,
sdp_lines, sdp_lines,
content_names,
triggers_no_cancel=True, triggers_no_cancel=True,
) )
@ -196,7 +207,7 @@ def generate_sdp_from_session(session: dict, local: bool = False, port: int = 9)
crypto_line += f" {session_params}" crypto_line += f" {session_params}"
sdp_lines.append(crypto_line) sdp_lines.append(crypto_line)
host.trigger.point( G.host.trigger.point(
"XEP-0167_generate_sdp_content", "XEP-0167_generate_sdp_content",
session, session,
local, local,
@ -214,6 +225,62 @@ def generate_sdp_from_session(session: dict, local: bool = False, port: int = 9)
return "\r\n".join(sdp_lines) + "\r\n" return "\r\n".join(sdp_lines) + "\r\n"
def generate_content_add_sdp(content_data: dict, session: dict, content_name: str) -> str:
"""Generate the SDP m-line of a content, from the peer description and
transport data, to be used as remote description by the frontend
@param content_data: The content data, as stored in ``session["contents"]``.
@param session: session data.
@param content_name: name of the content (used as media ID, as per JSEP).
@return: The SDP m-line (with its a= lines), without the session header.
"""
application_data = content_data["application_data"]
media = application_data["media"]
media_data = application_data["peer_data"]
ice_data = content_data["transport_data"]["peer_ice_data"]
payload_types = media_data.get("payload_types", {})
sdp_lines = [
f"m={media} 9 UDP/TLS/RTP/SAVPF "
+ " ".join(str(pt_id) for pt_id in payload_types)
]
sdp_lines.append(f"a=mid:{content_name}")
sdp_lines.append("c=IN IP4 0.0.0.0")
sdp_lines.append(senders_to_sdp(content_data["senders"], session))
for pt_id, pt in payload_types.items():
name = pt["name"]
clockrate = pt.get("clockrate", "")
channels = pt.get("channels")
if channels:
sdp_lines.append(f"a=rtpmap:{pt_id} {name}/{clockrate}/{channels}")
else:
sdp_lines.append(f"a=rtpmap:{pt_id} {name}/{clockrate}")
if "parameters" in pt:
fmtp_params = ";".join(f"{k}={v}" for k, v in pt["parameters"].items())
sdp_lines.append(f"a=fmtp:{pt_id} {fmtp_params}")
if media_data.get("rtcp-mux"):
sdp_lines.append("a=rtcp-mux")
fingerprint_data = ice_data.get("fingerprint")
if fingerprint_data:
sdp_lines.append(
f"a=fingerprint:{fingerprint_data['hash']} "
f"{fingerprint_data['fingerprint']}"
)
if "setup" in fingerprint_data:
sdp_lines.append(f"a=setup:{fingerprint_data['setup']}")
sdp_lines.append(f"a=ice-ufrag:{ice_data['ufrag']}")
sdp_lines.append(f"a=ice-pwd:{ice_data['pwd']}")
for candidate in ice_data.get("candidates", []):
sdp_lines.append(f"a={generate_candidate_line(candidate)}")
return "\r\n".join(sdp_lines) + "\r\n"
def generate_candidate_line(candidate: dict) -> str: def generate_candidate_line(candidate: dict) -> str:
"""Generate a ``candidate:`` attribute line from candidate data. """Generate a ``candidate:`` attribute line from candidate data.
@ -274,30 +341,39 @@ def parse_candidate(parts: list[str]) -> dict:
return candidate return candidate
def parse_sdp(sdp: str, role: str) -> dict: def parse_mline(
"""Parse SDP string. mline_sdp: str,
role: Literal["initiator", "responder"],
session_attributes: Optional[Dict[str, Any]] = None,
call_data: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Tuple[str, Dict[str, Any]]:
"""Parse an SDP media block (m= line and its a= lines)
@param sdp: The SDP string to parse. Can be used on a media section of a full SDP (see parse_sdp) or on a
@param role: Role of the entities which produces the SDP. standalone m-line block (as sent by the frontend).
@return: A dictionary containing parsed session data. @param mline_sdp: the m= line and its a= lines
@param role: role of the entity which produces the SDP
@param session_attributes: session-level attributes to apply to the media
@param call_data: passed to the ``XEP-0167_parse_sdp_a`` trigger
@param metadata: passed to the ``XEP-0167_parse_sdp_a`` trigger
@return: (media type, media data)
""" """
assert host is not None if call_data is None:
lines = sdp.strip().split("\r\n") call_data = {"metadata": metadata if metadata is not None else {}}
metadata: Dict[str, Any] = {}
call_data = {"metadata": metadata} senders = "both"
session_attributes: Dict[str, Any] = {} for attr, value in (session_attributes or {}).items():
if attr in ("sendrecv", "sendonly", "recvonly", "inactive"):
senders = value
media_type = None media_type = None
media_data: Optional[Dict[str, Any]] = None media_data: Optional[Dict[str, Any]] = None
application_data: Optional[Dict[str, Any]] = None application_data: Optional[Dict[str, Any]] = None
transport_data: Optional[Dict[str, Any]] = None transport_data: Optional[Dict[str, Any]] = None
fingerprint_data: Optional[Dict[str, str]] = None
ice_pwd: Optional[str] = None
ice_ufrag: Optional[str] = None
payload_types: Optional[Dict[int, dict]] = None payload_types: Optional[Dict[int, dict]] = None
senders: str = "both"
for line in lines: for line in mline_sdp.strip().split("\r\n"):
try: try:
parts = line.split() parts = line.split()
prefix = parts[0][:2] prefix = parts[0][:2]
@ -319,22 +395,22 @@ def parse_sdp(sdp: str, role: str) -> dict:
) )
transport_data = {"port": port} transport_data = {"port": port}
if fingerprint_data is not None: media_data = {
transport_data["fingerprint"] = fingerprint_data
if ice_pwd is not None:
transport_data["pwd"] = ice_pwd
if ice_ufrag is not None:
transport_data["ufrag"] = ice_ufrag
media_data = call_data[media_type] = {
"application_data": application_data, "application_data": application_data,
"transport_data": transport_data, "transport_data": transport_data,
"senders": senders, "senders": senders,
} }
# Apply session attributes to the new media # Apply session attributes to the new media, unless overridden at
for attr, value in session_attributes.items(): # the media level (RFC 4566 §5.7). The fingerprint dict is copied,
if attr in ("fingerprint", "ice-pwd", "ice-ufrag"): # as the media-level a= lines update it in place.
transport_data[attr] = value for attr, value in (session_attributes or {}).items():
if attr == "fingerprint":
transport_data[attr] = dict(value)
elif attr == "ice-pwd":
transport_data["pwd"] = value
elif attr == "ice-ufrag":
transport_data["ufrag"] = value
elif attr in ("sendrecv", "sendonly", "recvonly", "inactive"): elif attr in ("sendrecv", "sendonly", "recvonly", "inactive"):
application_data["senders"] = value application_data["senders"] = value
else: else:
@ -346,31 +422,7 @@ def parse_sdp(sdp: str, role: str) -> dict:
else: else:
attribute = parts[0] attribute = parts[0]
if media_type is None: if media_type is not None:
# This is a session-level attribute
if attribute == "fingerprint":
algorithm, fingerprint = parts[0], parts[1]
session_attributes["fingerprint"] = {
"hash": algorithm,
"fingerprint": fingerprint,
}
elif attribute == "ice-pwd":
session_attributes["ice-pwd"] = parts[0]
elif attribute == "ice-ufrag":
session_attributes["ice-ufrag"] = parts[0]
elif attribute in ("sendrecv", "sendonly", "recvonly", "inactive"):
if attribute == "sendrecv":
value = "both"
elif attribute == "sendonly":
value = role
elif attribute == "recvonly":
value = "responder" if role == "initiator" else "initiator"
else:
value = "none"
session_attributes[attribute] = value
else:
session_attributes[attribute] = parts[0] if parts else True
else:
# This is a media-level attribute # This is a media-level attribute
if attribute == "mid": if attribute == "mid":
assert media_data is not None assert media_data is not None
@ -424,10 +476,9 @@ def parse_sdp(sdp: str, role: str) -> dict:
elif attribute == "fingerprint": elif attribute == "fingerprint":
algorithm, fingerprint = parts[0], parts[1] algorithm, fingerprint = parts[0], parts[1]
fingerprint_data = {"hash": algorithm, "fingerprint": fingerprint}
if transport_data is not None: if transport_data is not None:
transport_data.setdefault("fingerprint", {}).update( transport_data.setdefault("fingerprint", {}).update(
fingerprint_data {"hash": algorithm, "fingerprint": fingerprint}
) )
elif attribute == "setup": elif attribute == "setup":
assert transport_data is not None assert transport_data is not None
@ -466,7 +517,7 @@ def parse_sdp(sdp: str, role: str) -> dict:
else: else:
application_data["senders"] = value application_data["senders"] = value
host.trigger.point( G.host.trigger.point(
"XEP-0167_parse_sdp_a", "XEP-0167_parse_sdp_a",
attribute, attribute,
parts, parts,
@ -483,6 +534,93 @@ def parse_sdp(sdp: str, role: str) -> dict:
except IndexError as e: except IndexError as e:
raise IndexError(f"Incomplete line. Missing data: {line}") from e raise IndexError(f"Incomplete line. Missing data: {line}") from e
assert media_type is not None and media_data is not None
return media_type, media_data
def parse_sdp(sdp: str, role: Literal["initiator", "responder"]) -> dict:
"""Parse SDP string.
@param sdp: The SDP string to parse.
@param role: Role of the entities which produces the SDP.
@return: A dictionary containing parsed session data.
"""
lines = sdp.strip().split("\r\n")
metadata: Dict[str, Any] = {}
call_data = {"metadata": metadata}
session_attributes: Dict[str, Any] = {}
media_block: List[str] = []
def flush_media_block() -> None:
if media_block:
media_type, media_data = parse_mline(
"\r\n".join(media_block), role, session_attributes, call_data, metadata
)
call_data[media_type] = media_data
media_block.clear()
for line in lines:
if line.startswith("m="):
flush_media_block()
media_block.append(line)
elif media_block and line.startswith("a="):
media_block.append(line)
else:
# session-level line
try:
parts = line.split()
prefix = parts[0][:2]
parts[0] = parts[0][2:]
if prefix == "a=":
if ":" in parts[0]:
attribute, parts[0] = parts[0].split(":", 1)
else:
attribute = parts[0]
if attribute == "fingerprint":
algorithm, fingerprint = parts[0], parts[1]
session_attributes["fingerprint"] = {
"hash": algorithm,
"fingerprint": fingerprint,
}
elif attribute == "ice-pwd":
session_attributes["ice-pwd"] = parts[0]
elif attribute == "ice-ufrag":
session_attributes["ice-ufrag"] = parts[0]
elif attribute in ("sendrecv", "sendonly", "recvonly", "inactive"):
if attribute == "sendrecv":
value = "both"
elif attribute == "sendonly":
value = role
elif attribute == "recvonly":
value = "responder" if role == "initiator" else "initiator"
else:
value = "none"
session_attributes[attribute] = value
else:
session_attributes[attribute] = parts[0] if parts else True
G.host.trigger.point(
"XEP-0167_parse_sdp_a",
attribute,
parts,
call_data,
metadata,
None,
None,
None,
triggers_no_cancel=True,
)
except ValueError as e:
raise ValueError(
f"Could not parse line. Invalid format ({e}): {line}"
) from e
except IndexError as e:
raise IndexError(f"Incomplete line. Missing data: {line}") from e
flush_media_block()
# we remove private data (data starting with _, used by some plugins (e.g. XEP-0294) # we remove private data (data starting with _, used by some plugins (e.g. XEP-0294)
# to handle session data at media level)) # to handle session data at media level))
for key in [k for k in call_data if k.startswith("_")]: for key in [k for k in call_data if k.startswith("_")]:
@ -536,9 +674,6 @@ def build_description(media: str, media_data: dict, session: dict) -> domish.Ele
@return: A <description> element. @return: A <description> element.
""" """
# FIXME: to be removed once host is accessible from global var
assert host is not None
desc_elt = domish.Element((NS_JINGLE_RTP, "description"), attribs={"media": media}) desc_elt = domish.Element((NS_JINGLE_RTP, "description"), attribs={"media": media})
for pt_id, pt_data in media_data.get("payload_types", {}).items(): for pt_id, pt_data in media_data.get("payload_types", {}).items():
@ -553,7 +688,7 @@ def build_description(media: str, media_data: dict, session: dict) -> domish.Ele
param_elt = payload_type_elt.addElement("parameter") param_elt = payload_type_elt.addElement("parameter")
param_elt["name"] = param_name param_elt["name"] = param_name
param_elt["value"] = param_value param_elt["value"] = param_value
host.trigger.point( G.host.trigger.point(
"XEP-0167_build_description_payload_type", "XEP-0167_build_description_payload_type",
desc_elt, desc_elt,
media_data, media_data,
@ -580,7 +715,7 @@ def build_description(media: str, media_data: dict, session: dict) -> domish.Ele
if attr in enc_data: if attr in enc_data:
crypto_elt[attr] = enc_data[attr] crypto_elt[attr] = enc_data[attr]
host.trigger.point( G.host.trigger.point(
"XEP-0167_build_description", "XEP-0167_build_description",
desc_elt, desc_elt,
media_data, media_data,
@ -597,9 +732,6 @@ def parse_description(desc_elt: domish.Element) -> dict:
@param desc_elt: <description> element @param desc_elt: <description> element
@return: media data as in [build_description] @return: media data as in [build_description]
""" """
# FIXME: to be removed once host is accessible from global var
assert host is not None
media_data = {} media_data = {}
if desc_elt.hasAttribute("ssrc"): if desc_elt.hasAttribute("ssrc"):
media_data.setdefault("ssrc", {})[desc_elt["ssrc"]] = {} media_data.setdefault("ssrc", {})[desc_elt["ssrc"]] = {}
@ -637,7 +769,7 @@ def parse_description(desc_elt: domish.Element) -> dict:
if parameters: if parameters:
payload_type_data["parameters"] = parameters payload_type_data["parameters"] = parameters
host.trigger.point( G.host.trigger.point(
"XEP-0167_parse_description_payload_type", "XEP-0167_parse_description_payload_type",
desc_elt, desc_elt,
media_data, media_data,
@ -688,7 +820,7 @@ def parse_description(desc_elt: domish.Element) -> dict:
if encryption_data: if encryption_data:
media_data["encryption"] = encryption_data media_data["encryption"] = encryption_data
host.trigger.point( G.host.trigger.point(
"XEP-0167_parse_description", desc_elt, media_data, triggers_no_cancel=True "XEP-0167_parse_description", desc_elt, media_data, triggers_no_cancel=True
) )

View file

@ -0,0 +1,30 @@
#!/usr/bin/env python3
# Libervia plugin for Jingle RTP (XEP-0167)
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from libervia.backend.plugins.plugin_xep_0166.models import ContentChangePayload
class RTPContentChangePayload(ContentChangePayload):
"""Payload for the Jingle RTP application"""
media: str
sdp: str | None = None
# new direction of the content (content-modify), from the peer perspective
direction: str | None = None
# origin of the added content, an entity URL (e.g. in a conference)
origin: str | None = None

View file

@ -16,7 +16,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Dict, List, Optional from typing import List
import uuid import uuid
from twisted.internet import defer from twisted.internet import defer
@ -253,39 +253,41 @@ class XEP_0176(BaseTransportHandler):
@param session: Jingle session. @param session: Jingle session.
@param content_name: Name (ID) of the content. @param content_name: Name (ID) of the content.
@param content_data: Data of the content. @param content_data: Data of the content.
@param media_type: Type of media of the content.
""" """
media_type = content_data["application_data"].get("media") media_type = content_data["application_data"].get("media")
# we check if we have any buffered ICE candidates, and send them if it's the case # we check if we have any buffered ICE candidates, and send them if it's the case
try: buffer = session.get("XEP-0176_ice_buffer")
buffer = session["XEP-0176_ice_buffer"] if not buffer:
buffered_ice_data = buffer.pop(media_type) return
except KeyError: buffered_ice_data = buffer.pop(content_name, None)
pass if buffered_ice_data is None:
else: # legacy frontends key the buffer by media type
if not buffer: buffered_ice_data = buffer.pop(media_type, None)
del session["XEP-0176_ice_buffer"] if buffered_ice_data is None:
local_jid = session["local_jid"] return
peer_jid = session["peer_jid"] if not buffer:
log.debug(f"Replaying ICE buffer from {local_jid} to {peer_jid}") del session["XEP-0176_ice_buffer"]
transport_elt = self.build_transport(buffered_ice_data) local_jid = session["local_jid"]
iq_elt, __ = self._j.build_action( peer_jid = session["peer_jid"]
client, log.debug(f"Replaying ICE buffer from {local_jid} to {peer_jid}")
self._j.A_TRANSPORT_INFO, transport_elt = self.build_transport(buffered_ice_data)
session, iq_elt, __ = self._j.build_action(
content_name, client,
context_elt=transport_elt, self._j.A_TRANSPORT_INFO,
) session,
self.host.trigger.point( content_name,
"XEP-0176_jingle_handler_send_buffer", context_elt=transport_elt,
client, )
session, self.host.trigger.point(
content_name, "XEP-0176_jingle_handler_send_buffer",
content_data, client,
transport_elt, session,
iq_elt, content_name,
) content_data,
await iq_elt.send() transport_elt,
iq_elt,
)
await iq_elt.send()
async def jingle_handler( async def jingle_handler(
self, self,
@ -308,10 +310,19 @@ class XEP_0176(BaseTransportHandler):
""" """
content_data = session["contents"][content_name] content_data = session["contents"][content_name]
transport_data = content_data["transport_data"] transport_data = content_data["transport_data"]
if action in (self._j.A_PREPARE_CONFIRMATION, self._j.A_PREPARE_INITIATOR): if action in (
self._j.A_PREPARE_CONFIRMATION,
self._j.A_PREPARE_INITIATOR,
# a content-add comes with the peer transport, we parse it as well
self._j.A_CONTENT_ADD,
):
peer_ice_data = self.parse_transport(transport_elt) peer_ice_data = self.parse_transport(transport_elt)
transport_data["peer_ice_data"] = peer_ice_data transport_data["peer_ice_data"] = peer_ice_data
elif action == self._j.A_CONTENT_MODIFY:
# only the senders attribute is changed, nothing to do here
pass
elif action == self._j.A_ACCEPTED_ACK: elif action == self._j.A_ACCEPTED_ACK:
buffer = session.pop("XEP-0176_handler_buffer", None) buffer = session.pop("XEP-0176_handler_buffer", None)
if buffer: if buffer:
@ -343,7 +354,6 @@ class XEP_0176(BaseTransportHandler):
buffer.append([client, action, session, content_name, transport_elt]) buffer.append([client, action, session, content_name, transport_elt])
return transport_elt return transport_elt
media_type = content_data["application_data"].get("media")
new_ice_data = self.parse_transport(transport_elt) new_ice_data = self.parse_transport(transport_elt)
restart = self.update_candidates(transport_data, new_ice_data, local=False) restart = self.update_candidates(transport_data, new_ice_data, local=False)
if restart: if restart:
@ -354,7 +364,9 @@ class XEP_0176(BaseTransportHandler):
self.host.bridge.ice_restart(session["id"], "peer", client.profile) self.host.bridge.ice_restart(session["id"], "peer", client.profile)
ice_candidates_new_cb = session.get("ice_candidates_new_cb") ice_candidates_new_cb = session.get("ice_candidates_new_cb")
ice_candidates_data = {media_type: new_ice_data} # the content name is used as key, as it is unique in the session (unlike the
# media type, which can be duplicated)
ice_candidates_data = {content_name: new_ice_data}
if ice_candidates_new_cb is None: if ice_candidates_new_cb is None:
self.host.bridge.ice_candidates_new( self.host.bridge.ice_candidates_new(
session["id"], session["id"],
@ -409,75 +421,85 @@ class XEP_0176(BaseTransportHandler):
return False return False
async def ice_candidates_add( async def ice_candidates_add(
self, client: SatXMPPEntity, session_id: str, media_ice_data: dict[str, dict] self, client: SatXMPPEntity, session_id: str, content_ice_data: dict[str, dict]
) -> None: ) -> None:
"""Called when a new ICE candidates are available for a session """Called when new ICE candidates are available for a session
@param session_id: Session ID @param content_ice_data: a map from content name to ICE data (see
@param media_ice_data: a map from media type (audio, video) to ICE data [self.parse_transport]); a media type can also be used as key.
ICE data must be in the same format as in [self.parse_transport]
""" """
# FIXME: frontends still use media types as key, they need to be updated
session = self._j.get_session(client, session_id) session = self._j.get_session(client, session_id)
iq_elt: Optional[domish.Element] = None # FIXME: we use one transport-info IQ per content, as multi-content stanzas are
content_name: str | None = None # rejected by some clients (e.g. Dino)
content_data: dict | None = None iqs: list[tuple[dict, str, dict, domish.Element]] = []
for key, new_ice_data in content_ice_data.items():
if key in session["contents"]:
content_names = [key]
else:
# legacy: key is a media type
content_names = [
name
for name, content_data in session["contents"].items()
if content_data["application_data"].get("media") == key
]
if not content_names:
log.warning(f"no content found for {key!r}")
continue
for media_type, new_ice_data in media_ice_data.items():
if session["state"] == self._j.STATE_PENDING: if session["state"] == self._j.STATE_PENDING:
log.debug(f"session not active, buffering") log.debug(f"session not active, buffering")
buffer = session.setdefault("XEP-0176_ice_buffer", {}) buffer = session.setdefault("XEP-0176_ice_buffer", {})
media_buffer = buffer.setdefault(media_type, {}) for content_name in content_names:
content_buffer = buffer.setdefault(content_name, {})
for key in ["ufrag", "pwd"]: for ice_key in ["ufrag", "pwd"]:
if key not in media_buffer: if ice_key not in content_buffer:
media_buffer[key] = new_ice_data[key] content_buffer[ice_key] = new_ice_data[ice_key]
else: else:
if media_buffer[key] != new_ice_data[key]: if content_buffer[ice_key] != new_ice_data[ice_key]:
log.warning( log.warning(
f"{key} conflict, new value will replace old one\n" f"{ice_key} conflict, new value will replace old"
f"buffer={media_buffer[key]!r}\n" " one\n"
f"new={new_ice_data[key]!r}" f"buffer={content_buffer[ice_key]!r}\n"
) f"new={new_ice_data[ice_key]!r}"
media_buffer[key] = new_ice_data[key] )
content_buffer[ice_key] = new_ice_data[ice_key]
media_buffer.setdefault("candidates", []).extend( content_buffer.setdefault("candidates", []).extend(
new_ice_data["candidates"] new_ice_data["candidates"]
) )
continue continue
for content_name, content_data in session["contents"].items(): for content_name in content_names:
if content_data["application_data"].get("media") == media_type: content_data = session["contents"][content_name]
break restart = self.update_candidates(
else: content_data["transport_data"], new_ice_data, True
log.warning(f"no media of type {media_type} has been found")
continue
restart = self.update_candidates(
content_data["transport_data"], new_ice_data, True
)
if restart:
log.debug(
f"Local ICE restart detected on session {session['id']} "
f"[{client.profile}]"
) )
self.host.bridge.ice_restart(session["id"], "local", client.profile) if restart:
transport_elt = self.build_transport(new_ice_data) log.debug(
iq_elt, __ = self._j.build_action( f"Local ICE restart detected on session {session['id']} "
client, f"[{client.profile}]"
self._j.A_TRANSPORT_INFO, )
session, self.host.bridge.ice_restart(session["id"], "local", client.profile)
content_name, transport_elt = self.build_transport(new_ice_data)
iq_elt=iq_elt, iq_elt, __ = self._j.build_action(
context_elt=transport_elt, client,
) self._j.A_TRANSPORT_INFO,
session,
content_name,
context_elt=transport_elt,
)
iqs.append((new_ice_data, content_name, content_data, iq_elt))
if iq_elt is not None: for new_ice_data, content_name, content_data, iq_elt in iqs:
assert content_name is not None and content_data is not None
try: try:
self.host.trigger.point( self.host.trigger.point(
"XEP-0176_ice_candidate_send", "XEP-0176_ice_candidate_send",
client, client,
session, session,
media_ice_data, new_ice_data,
content_name, content_name,
content_data, content_data,
iq_elt, iq_elt,

View file

@ -58,6 +58,9 @@ class XEP_0338:
host.trigger.add( host.trigger.add(
"XEP-0167_jingle_session_init", self._jingle_session_init_trigger "XEP-0167_jingle_session_init", self._jingle_session_init_trigger
) )
host.trigger.add(
"XEP-0167_content_add_elt_built", self._content_add_elt_built_trigger
)
host.trigger.add("XEP-0167_jingle_handler", self._jingle_handler_trigger) host.trigger.add("XEP-0167_jingle_handler", self._jingle_handler_trigger)
def get_handler(self, client): def get_handler(self, client):
@ -84,13 +87,41 @@ class XEP_0338:
session: dict, session: dict,
local: bool, local: bool,
sdp_lines: List[str], sdp_lines: List[str],
content_names: List[str] | None = None,
) -> None: ) -> None:
"""Generate "group" attributes""" """Generate "group" attributes"""
if content_names is not None:
# the session groups describe the full session: only the generated m-lines
# sharing a single transport are bundled
ice_key = "local_ice_data" if local else "peer_ice_data"
for names in self._group_by_transport(
session, content_names, ice_key
).values():
if len(names) > 1:
sdp_lines.append(f"a=group:BUNDLE {' '.join(names)}")
return
key = "metadata" if local else "peer_metadata" key = "metadata" if local else "peer_metadata"
group_data = session[key].get("group", {}) group_data = session[key].get("group", {})
for semantics, content_names in group_data.items(): for semantics, names in group_data.items():
sdp_lines.append(f"a=group:{semantics} {' '.join(content_names)}") sdp_lines.append(f"a=group:{semantics} {' '.join(names)}")
def _group_by_transport(
self, session: dict, content_names: List[str], ice_key: str
) -> dict[tuple[str, str], List[str]]:
"""Group content names sharing a single transport
@param session: session data
@param content_names: names of the contents to group
@param ice_key: transport data key holding the credentials ("local_ice_data" or
"peer_ice_data")
@return: dict mapping (ufrag, pwd) to the names sharing the transport
"""
groups: dict[tuple[str, str], List[str]] = {}
for name in content_names:
ice_data = session["contents"][name]["transport_data"][ice_key]
groups.setdefault((ice_data["ufrag"], ice_data["pwd"]), []).append(name)
return groups
def parse_group_element(self, jingle_elt: domish.Element, session: dict) -> None: def parse_group_element(self, jingle_elt: domish.Element, session: dict) -> None:
"""Parse the <group> and <content> elements""" """Parse the <group> and <content> elements"""
@ -104,18 +135,60 @@ class XEP_0338:
except KeyError as e: except KeyError as e:
log.warning(f"Error while parsing <group>: {e}\n{group_elt.toXml()}") log.warning(f"Error while parsing <group>: {e}\n{group_elt.toXml()}")
def add_group_element(self, jingle_elt: domish.Element, session: dict) -> None: def _add_group_elt(
"""Build the <group> and <content> elements if possible""" self,
for semantics, content_names in session["metadata"].get("group", {}).items(): jingle_elt: domish.Element,
# always add the <group> element as first child of <jingle> element to work semantics: str,
# around https://github.com/movim/movim/issues/1233 content_names: List[str],
group_elt = domish.Element((NS_JINGLE_GROUPING, "group")) ) -> None:
jingle_elt.children.insert(0, group_elt) """Add a <group> element with the given contents
group_elt["semantics"] = semantics @param jingle_elt: <jingle> element the group is added to
for content_name in content_names: @param semantics: grouping semantics (e.g. "BUNDLE")
content_elt = group_elt.addElement((NS_JINGLE_GROUPING, "content")) @param content_names: names of the grouped contents
content_elt["name"] = content_name """
group_elt = domish.Element((NS_JINGLE_GROUPING, "group"))
jingle_elt.addChild(group_elt)
group_elt["semantics"] = semantics
for content_name in content_names:
content_elt = group_elt.addElement((NS_JINGLE_GROUPING, "content"))
content_elt["name"] = content_name
def add_group_element(
self, jingle_elt: domish.Element, session: dict, source: str = "metadata"
) -> None:
"""Build the <group> and <content> elements if possible
@param jingle_elt: <jingle> element the groups are added to
@param session: session data
@param source: session key holding the group data ("metadata" for the local
groups, "peer_metadata" to echo the peer's groups)
"""
for semantics, content_names in session.get(source, {}).get("group", {}).items():
self._add_group_elt(jingle_elt, semantics, content_names)
def _content_add_elt_built_trigger(
self,
client: SatXMPPEntity,
session: dict,
jingle_elt: domish.Element,
added: List[str],
) -> None:
"""Add the BUNDLE groups of a sent content-add
The added contents are bundled with the other contents sharing their transport.
@param client: the client entity
@param session: session data
@param jingle_elt: <jingle> element of the content-add to be sent
@param added: names of the contents added to the session
"""
for names in self._group_by_transport(
session, list(session["contents"]), "local_ice_data"
).values():
# the <group> element lists the full bundle, not only the added contents
if len(names) > 1 and any(name in names for name in added):
self._add_group_elt(jingle_elt, "BUNDLE", names)
def _jingle_session_init_trigger( def _jingle_session_init_trigger(
self, self,
@ -149,7 +222,7 @@ class XEP_0338:
jingle_elt = session["jingle_elt"] jingle_elt = session["jingle_elt"]
self.parse_group_element(jingle_elt, session) self.parse_group_element(jingle_elt, session)
if action == self._j.A_SESSION_INITIATE: if action == self._j.A_SESSION_INITIATE:
self.add_group_element(jingle_elt, session) self.add_group_element(jingle_elt, session, source="peer_metadata")
@implementer(itx_xmpp.IDisco) @implementer(itx_xmpp.IDisco)

View file

@ -177,7 +177,7 @@ class XEP_0343(BaseTransportHandler):
) -> None: ) -> None:
call_data = content["app_kwargs"]["extra"].pop("call_data", None) call_data = content["app_kwargs"]["extra"].pop("call_data", None)
if call_data: if call_data:
metadata = self._rtp.parse_call_data(call_data) metadata = self._rtp.parse_call_data(call_data, self._j.ROLE_INITIATOR)
try: try:
application_data = call_data["application"] application_data = call_data["application"]
except KeyError: except KeyError:

View file

@ -141,6 +141,7 @@ def client_IQ():
@fixture @fixture
def client(): def client():
client = MagicMock() client = MagicMock()
client.is_component = False
client.jid = jid.JID("test_user@test.example/123") client.jid = jid.JID("test_user@test.example/123")
client.server_jid = jid.JID("test.example") client.server_jid = jid.JID("test.example")
client.pubsub_service = jid.JID("pubsub.test.example") client.pubsub_service = jid.JID("pubsub.test.example")

View file

@ -0,0 +1,94 @@
#!/usr/bin/env python3
# Libervia: an XMPP client
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from unittest.mock import MagicMock
from libervia.backend.plugins.plugin_xep_0166 import XEP_0166
from libervia.backend.tools.common import data_format
class TestXEP0166ContentChangeSignal:
def _get_session(self, content_name="video"):
mock_application = MagicMock()
mock_application.handler.get_content_change_payload.return_value = {
"media": "video",
}
return {
"id": "123",
"added_content_names": [content_name],
"contents": {
content_name: {
"senders": "initiator",
"application": mock_application,
}
},
}
def test_emit_content_change(self, host, client):
"""The signal carries the session data and the application extra data"""
xep_0166 = XEP_0166(host)
mock_signal = MagicMock()
host.bridge.jingle_content_change = mock_signal
session = self._get_session()
xep_0166._emit_content_change(client, XEP_0166.A_CONTENT_ADD, session)
args = mock_signal.call_args.args
assert args[1] == client.profile
info = data_format.deserialise(args[0])
assert info["session_id"] == "123"
assert info["action"] == XEP_0166.A_CONTENT_ADD
assert info["changes"] == [
{"content": "video", "senders": "initiator", "payload": {"media": "video"}}
]
session["contents"]["video"][
"application"
].handler.get_content_change_payload.assert_called_once_with(
client, XEP_0166.A_CONTENT_ADD, session, "video"
)
def test_emit_content_change_multi_content(self, host, client):
"""A stanza with several contents emits a single signal with all the changes"""
xep_0166 = XEP_0166(host)
mock_signal = MagicMock()
host.bridge.jingle_content_change = mock_signal
session = self._get_session("video")
session["added_content_names"] = ["video", "audio"]
session["contents"]["audio"] = session["contents"]["video"]
xep_0166._emit_content_change(client, XEP_0166.A_CONTENT_ADD, session)
assert mock_signal.call_count == 1
info = data_format.deserialise(mock_signal.call_args.args[0])
changes = [change["content"] for change in info["changes"]]
assert changes == ["video", "audio"]
def test_emit_content_change_unknown_content(self, host, client):
"""When the content is unknown, no signal is emitted and no exception raised"""
xep_0166 = XEP_0166(host)
mock_signal = MagicMock()
host.bridge.jingle_content_change = mock_signal
session = {
"id": "123",
"added_content_names": ["video"],
"contents": {},
}
xep_0166._emit_content_change(client, XEP_0166.A_CONTENT_ADD, session)
mock_signal.assert_not_called()

View file

@ -22,13 +22,19 @@ from unittest.mock import MagicMock, patch
from pytest import fixture from pytest import fixture
from pytest import raises from pytest import raises
from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber import jid
from twisted.words.xish import domish
from libervia.backend.plugins.plugin_xep_0166 import XEP_0166 from libervia.backend.plugins.plugin_xep_0166 import XEP_0166, NS_JINGLE
from libervia.backend.plugins.plugin_xep_0166.models import (
ContentChangePayload,
TransportData,
)
from libervia.backend.plugins.plugin_xep_0167 import XEP_0167, mapping from libervia.backend.plugins.plugin_xep_0167 import XEP_0167, mapping
from libervia.backend.plugins.plugin_xep_0167.constants import ( from libervia.backend.plugins.plugin_xep_0167.constants import (
NS_JINGLE_RTP, NS_JINGLE_RTP,
NS_JINGLE_RTP_INFO, NS_JINGLE_RTP_INFO,
) )
from libervia.backend.plugins.plugin_xep_0176 import NS_JINGLE_ICE_UDP
from libervia.backend.tools import xml_tools from libervia.backend.tools import xml_tools
from libervia.backend.tools.common import data_format from libervia.backend.tools.common import data_format
@ -40,10 +46,6 @@ def no_application_register(monkeypatch):
class TestXEP0167Mapping: class TestXEP0167Mapping:
@fixture(scope="class", autouse=True)
def set_mapping_host(self, host):
mapping.host = host
def test_senders_to_sdp(self): def test_senders_to_sdp(self):
"""Senders are mapped to SDP attribute""" """Senders are mapped to SDP attribute"""
assert mapping.senders_to_sdp("both", {"role": "initiator"}) == "a=sendrecv" assert mapping.senders_to_sdp("both", {"role": "initiator"}) == "a=sendrecv"
@ -112,6 +114,68 @@ class TestXEP0167Mapping:
assert mapping.generate_sdp_from_session(session, True) == expected_sdp assert mapping.generate_sdp_from_session(session, True) == expected_sdp
def test_generate_content_add_sdp(self):
"""The m-line of a content added by the peer is correctly generated"""
content_data = {
"application_data": {
"media": "video",
"peer_data": {
"payload_types": {
96: {
"name": "VP8",
"clockrate": 90000,
"parameters": {"max-fs": "12288"},
}
},
"rtcp-mux": True,
},
},
"transport_data": {
"peer_ice_data": {
"ufrag": "ufrag",
"pwd": "pwd",
"fingerprint": {
"hash": "sha-256",
"setup": "actpass",
"fingerprint": "AB:CD:EF",
},
"candidates": [
{
"foundation": "1",
"component_id": 1,
"transport": "UDP",
"priority": 1,
"address": "10.0.0.2",
"port": 54321,
"type": "host",
}
],
}
},
"senders": "initiator",
}
session = {"role": "responder"}
expected_sdp = (
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\n"
"a=mid:video\r\n"
"c=IN IP4 0.0.0.0\r\n"
"a=sendonly\r\n"
"a=rtpmap:96 VP8/90000\r\n"
"a=fmtp:96 max-fs=12288\r\n"
"a=rtcp-mux\r\n"
"a=fingerprint:sha-256 AB:CD:EF\r\n"
"a=setup:actpass\r\n"
"a=ice-ufrag:ufrag\r\n"
"a=ice-pwd:pwd\r\n"
"a=candidate:1 1 UDP 1 10.0.0.2 54321 typ host\r\n"
)
assert (
mapping.generate_content_add_sdp(content_data, session, "video")
== expected_sdp
)
def test_parse_sdp(self): def test_parse_sdp(self):
"""SDP is correctly parsed to session data""" """SDP is correctly parsed to session data"""
sdp = ( sdp = (
@ -170,6 +234,68 @@ class TestXEP0167Mapping:
assert mapping.parse_sdp(sdp, "responder") == expected_session assert mapping.parse_sdp(sdp, "responder") == expected_session
def test_parse_sdp_session_level_ice(self):
"""Session-level transport attributes apply to all media
unless overridden at the media level
media
"""
sdp = (
"v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\nt=0 0\r\n"
"a=fingerprint:sha-256 00:11:22\r\n"
"a=ice-pwd:spwd\r\n"
"m=audio 1 UDP/TLS/RTP/SAVPF 96\r\n"
"a=fingerprint:sha-256 33:44:55\r\n"
"a=ice-ufrag:sufrag\r\n"
"m=video 2 UDP/TLS/RTP/SAVPF 97\r\n"
"a=ice-ufrag:sufrag\r\n"
)
parsed = mapping.parse_sdp(sdp, "initiator")
# audio: the media-level fingerprint wins
assert parsed["audio"]["transport_data"]["fingerprint"] == {
"hash": "sha-256",
"fingerprint": "33:44:55",
}
# video: no media-level fingerprint, the session one applies (the audio
# override must not have polluted it)
assert parsed["video"]["transport_data"]["fingerprint"] == {
"hash": "sha-256",
"fingerprint": "00:11:22",
}
# the session-level ice-pwd applies to both media
assert parsed["audio"]["transport_data"]["pwd"] == "spwd"
assert parsed["video"]["transport_data"]["pwd"] == "spwd"
def test_parse_mline(self):
"""Test parse_mline on a standalone m-line block"""
mline = (
"m=audio 49170 UDP/TLS/RTP/SAVPF 0 96\r\n"
"c=IN IP4 0.0.0.0\r\n"
"a=rtcp-mux\r\n"
"a=sendonly\r\n"
"a=rtpmap:96 opus/48000/2\r\n"
"a=ice-ufrag:abc123\r\n"
"a=ice-pwd:def456\r\n"
"a=fingerprint:sha-256 00:11:22\r\n"
)
media, media_data = mapping.parse_mline(mline, "responder")
assert media == "audio"
assert media_data["application_data"]["senders"] == "responder"
assert media_data["application_data"]["payload_types"][96]["name"] == "opus"
assert media_data["transport_data"]["port"] == 49170
assert media_data["transport_data"]["ufrag"] == "abc123"
assert media_data["transport_data"]["pwd"] == "def456"
assert media_data["transport_data"]["fingerprint"]["fingerprint"] == "00:11:22"
# session attributes (as forwarded by parse_sdp) are applied to the media:
# with no media-level direction, the session one is used (a media-level
# a=sendonly would override it, remapped with the role)
mline_no_dir = mline.replace("a=sendonly\r\n", "")
media, media_data = mapping.parse_mline(
mline_no_dir, "responder", {"sendonly": "initiator"}
)
assert media_data["application_data"]["senders"] == "initiator"
def test_build_description(self): def test_build_description(self):
"""<description> element is generated from media data""" """<description> element is generated from media data"""
session = {"metadata": {}} session = {"metadata": {}}
@ -374,3 +500,265 @@ class TestXEP0167:
with raises(ValueError, match="Unkown info type 'invalid_action'"): with raises(ValueError, match="Unkown info type 'invalid_action'"):
xep_0167.send_info(client, session_id, "invalid_action", extra) xep_0167.send_info(client, session_id, "invalid_action", extra)
def test_content_change_payload_content_add(self, host, client):
"""For a content-add, the offered m-line SDP is returned in the payload"""
xep_0167 = XEP_0167(host)
session = {
"id": "123",
"role": "responder",
"contents": {
"video": {
"application_data": {
"media": "video",
"peer_data": {
"payload_types": {96: {"name": "VP8", "clockrate": 90000}},
},
},
"transport_data": {
"peer_ice_data": {
"ufrag": "uf",
"pwd": "pw",
"candidates": [],
}
},
"senders": "initiator",
}
},
}
payload = xep_0167.get_content_change_payload(
client, XEP_0166.A_CONTENT_ADD, session, "video"
)
assert payload.media == "video"
assert payload.sdp
assert "m=video 9 UDP/TLS/RTP/SAVPF 96" in payload.sdp
assert "a=rtpmap:96 VP8/90000" in payload.sdp
assert "a=ice-ufrag:uf" in payload.sdp
def test_content_change_payload_content_modify(self, host, client):
"""For a content-modify, the media and new direction are returned (no SDP)"""
xep_0167 = XEP_0167(host)
session = {
"id": "123",
"role": "responder",
"contents": {
"video": {
"application_data": {"media": "video"},
"senders": "both",
}
},
}
payload = xep_0167.get_content_change_payload(
client, XEP_0166.A_CONTENT_MODIFY, session, "video"
)
assert payload.model_dump(exclude_none=True) == {
"media": "video",
"direction": "sendrecv",
}
async def test_content_modify(self, host, client, client_IQ):
"""The modified senders attribute is updated and acknowledged"""
xep_0166 = XEP_0166(host)
client.IQ = client_IQ()
mock_application = MagicMock()
mock_application.handler.get_content_change_payload.return_value = (
ContentChangePayload()
)
session = {
"id": "123",
"local_jid": jid.JID("client1@example.org/1"),
"peer_jid": jid.JID("client2@example.net/2"),
"role": "responder",
"contents": {
"video": {
"senders": "initiator",
"creator": XEP_0166.ROLE_INITIATOR,
"application": mock_application,
"transport": MagicMock(),
}
},
}
mock_send = MagicMock()
client.send = mock_send
request = domish.Element((None, "iq"))
request["type"] = "set"
jingle_elt = request.addElement("jingle", NS_JINGLE)
jingle_elt["sid"] = "123"
jingle_elt["action"] = XEP_0166.A_CONTENT_MODIFY
content_elt = jingle_elt.addElement("content", NS_JINGLE)
content_elt["name"] = "video"
content_elt["senders"] = "both"
await xep_0166.on_content_modify(client, request, jingle_elt, session)
# the direction is updated
assert session["contents"]["video"]["senders"] == "both"
assert session["modified_content_names"] == ["video"]
# no contrary content-modify is sent, only the IQ result
stanzas = [call.args[0] for call in mock_send.call_args_list]
assert stanzas[0]["type"] == "result"
async def test_call_update(self, host, client, client_IQ):
"""New m-lines of the offered SDP are added with a content-add IQ"""
xep_0167 = XEP_0167(host)
client.IQ = client_IQ()
session_id = "123"
session = {
"id": session_id,
"local_jid": client.jid,
"peer_jid": jid.JID("room@conference.test.example"),
"role": "initiator",
"contents": {},
}
client.jingle_sessions = {session_id: session}
lines = [
"v=0",
"o=- 0 0 IN IP4 127.0.0.1",
"s=-",
"t=0 0",
"m=audio 9 UDP/TLS/RTP/SAVPF 111",
"c=IN IP4 0.0.0.0",
"a=rtcp-mux",
"a=mid:1-0",
"a=sendonly",
"a=rtpmap:111 opus/48000/2",
"a=ice-ufrag:uf1",
"a=ice-pwd:pw1",
]
added = await xep_0167.call_update(
client, session_id, {"sdp": "\r\n".join(lines)}
)
assert added == ["1-0"]
# the new content is sent with a content-add IQ
iq_elt = client.IQ.sent_iqs[0]
jingle_elt = list(iq_elt.elements(NS_JINGLE, "jingle"))[0]
assert jingle_elt["action"] == XEP_0166.A_CONTENT_ADD
content_elts = list(jingle_elt.elements(NS_JINGLE, "content"))
assert [c["name"] for c in content_elts] == ["1-0"]
def test_content_change_payload_content_accept(self, host, client):
"""For a content-accept, the answer m-line is generated"""
xep_0167 = XEP_0167(host)
# the real ICE-UDP transport handler, so the whole parsing chain is tested
# (XEP-0176 parsing + XEP-0320 fingerprint)
transport = TransportData(NS_JINGLE_ICE_UDP, host.plugins["XEP-0176"], 100)
desc_elt = domish.Element(
(NS_JINGLE_RTP, "description"), attribs={"media": "video"}
)
pt_elt = desc_elt.addElement("payload-type")
pt_elt["id"] = "96"
pt_elt["name"] = "VP8"
pt_elt["clockrate"] = "90000"
# the peer transport element, parsed by the XEP-0176 handler (fingerprint
# included by the XEP-0320 trigger)
peer_transport_elt = domish.Element(
(NS_JINGLE_ICE_UDP, "transport"), attribs={"ufrag": "uf", "pwd": "pw"}
)
fingerprint_elt = peer_transport_elt.addElement(
("urn:xmpp:jingle:apps:dtls:0", "fingerprint"), content="AB:CD"
)
fingerprint_elt["hash"] = "sha-256"
fingerprint_elt["setup"] = "actpass"
session = {
"id": "123",
"role": "responder",
"contents": {
"video": {
"application_data": {"media": "video"},
"transport": transport,
"transport_data": {},
"desc_elt": desc_elt,
"transport_elt": peer_transport_elt,
"senders": "responder",
}
},
}
payload = xep_0167.get_content_change_payload(
client, XEP_0166.A_CONTENT_ACCEPT, session, "video"
)
assert payload.media == "video"
# the peer data are parsed and stored in the session
assert session["contents"]["video"]["application_data"]["peer_data"]
assert session["contents"]["video"]["transport_data"]["peer_ice_data"]
assert payload.sdp
assert "m=video 9 UDP/TLS/RTP/SAVPF 96" in payload.sdp
# the direction is strict (senders=responder, we are the responder: the peer receives)
assert "a=recvonly" in payload.sdp
assert "a=fingerprint:sha-256 AB:CD" in payload.sdp
assert "a=ice-ufrag:uf" in payload.sdp
def test_call_content_accept(self, host, client, client_IQ):
"""A content-accept IQ is sent, built from the frontend answer m-line"""
xep_0167 = XEP_0167(host)
transport_elt = domish.Element(
(NS_JINGLE_ICE_UDP, "transport"), attribs={"ufrag": "uf", "pwd": "pw"}
)
mock_transport = MagicMock()
mock_transport.handler.build_transport = MagicMock(return_value=transport_elt)
client.IQ = client_IQ()
session = {
"id": "123",
"added_content_names": ["video"],
"local_jid": jid.JID("client1@example.org/1"),
"peer_jid": jid.JID("client2@example.net/2"),
"role": "responder",
"contents": {
"video": {
"application_data": {"media": "video"},
"transport": mock_transport,
"transport_data": {},
"senders": "initiator",
}
},
}
client.jingle_sessions = {"123": session}
host.get_client = MagicMock(return_value=client)
mock_send = MagicMock()
client.send = mock_send
mline = (
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\n"
"c=IN IP4 0.0.0.0\r\n"
"a=recvonly\r\n"
"a=rtpmap:96 VP8/90000\r\n"
"a=rtcp-mux\r\n"
"a=fingerprint:sha-256 AA:BB\r\n"
"a=setup:active\r\n"
"a=ice-ufrag:uf\r\n"
"a=ice-pwd:pw\r\n"
"a=candidate:1 1 UDP 1 10.0.0.1 12345 typ host\r\n"
)
xep_0167._call_content_accept("123", "video", mline, client.profile)
assert "added_content_names" not in session
# local data are stored in the session
assert session["contents"]["video"]["application_data"]["local_data"]
assert (
session["contents"]["video"]["transport_data"]["local_ice_data"]["ufrag"]
== "uf"
)
sent_iq = mock_send.call_args.args[0]
jingle_elt = sent_iq.firstChildElement()
assert jingle_elt["action"] == XEP_0166.A_CONTENT_ACCEPT
content_elt = jingle_elt.firstChildElement()
assert content_elt["name"] == "video"
assert content_elt["creator"] == XEP_0166.ROLE_INITIATOR
assert content_elt["senders"] == "initiator"
desc_elts = list(content_elt.elements(NS_JINGLE_RTP, "description"))
assert len(desc_elts) == 1
desc_elt = desc_elts[0]
assert desc_elt["media"] == "video"
payload_elts = list(desc_elt.elements(NS_JINGLE_RTP, "payload-type"))
assert len(payload_elts) == 1
assert payload_elts[0]["name"] == "VP8"
transport_elts = content_elt.elements(NS_JINGLE_ICE_UDP, "transport")
transport_elt = next(transport_elts)
assert transport_elt["ufrag"] == "uf"
assert transport_elt["pwd"] == "pw"

View file

@ -251,7 +251,7 @@ class TestXEP0176:
assert returned_transport_elt.toXml() == expected_transport_elt.toXml() assert returned_transport_elt.toXml() == expected_transport_elt.toXml()
@ed @ed
async def test_ice_candidates_add(self, host, client): async def test_ice_candidates_add(self, host, client, monkeypatch):
"""local new ICE candidates are added, IQ is sent, bridge signal emitted""" """local new ICE candidates are added, IQ is sent, bridge signal emitted"""
xep_0176 = XEP_0176(host) xep_0176 = XEP_0176(host)
@ -278,8 +278,9 @@ class TestXEP0176:
} }
mock_session = self.create_mock_session(content_name) mock_session = self.create_mock_session(content_name)
xep_0176._j.get_session = MagicMock(return_value=mock_session) get_session = MagicMock(return_value=mock_session)
xep_0176._j.build_action = MagicMock() monkeypatch.setattr(xep_0176._j, "get_session", get_session)
monkeypatch.setattr(xep_0176._j, "build_action", MagicMock())
iq_elt = AsyncMock() iq_elt = AsyncMock()
xep_0176._j.build_action.return_value = (iq_elt, None) xep_0176._j.build_action.return_value = (iq_elt, None)
xep_0176.host.bridge.ice_restart = MagicMock() xep_0176.host.bridge.ice_restart = MagicMock()

View file

@ -47,7 +47,8 @@ def xep_0272(host) -> XEP_0272:
"XEP-0045": MagicMock(), "XEP-0045": MagicMock(),
"XEP-0166": MagicMock(), "XEP-0166": MagicMock(),
"XEP-0167": MagicMock(), "XEP-0167": MagicMock(),
"XEP-0249": MagicMock(), "XEP-0402": MagicMock(),
"XEP-0482": MagicMock(),
} }
return XEP_0272(host) return XEP_0272(host)
@ -236,6 +237,30 @@ class TestXEP0272:
xep_0272.try_to_finish_preparation(client, room, muji_data) xep_0272.try_to_finish_preparation(client, room, muji_data)
mock_call_group_setup.assert_not_called() mock_call_group_setup.assert_not_called()
@ed
async def test_call_group_end(self, host, client):
"""call_group_end removes the MUJI presence data and the room bookmark."""
xep_0272 = XEP_0272(host)
room_jid = jid.JID("room@example.com")
room = MagicMock()
room.roomJID = room_jid
muji_data = {"to_call": {jid.JID("peer@example.com")}, "done_preparing": True}
with (
patch.object(xep_0272._muc, "get_room", return_value=room),
patch.object(xep_0272, "get_muji_data", return_value=muji_data),
patch.object(
client.presence, "send", return_value=defer.succeed(None)
) as mock_presence_send,
patch.object(
xep_0272._bookmarks, "remove", return_value=defer.succeed(None)
) as mock_remove,
):
await xep_0272.call_group_end(client, room_jid)
mock_presence_send.assert_called_once()
assert muji_data == {"to_call": set()}
mock_remove.assert_called_once_with(client, room_jid)
@ed @ed
async def test_call_group_data_set(self, host, client): async def test_call_group_data_set(self, host, client):
"""call_group_data_set sends correct presence and muji data.""" """call_group_data_set sends correct presence and muji data."""

View file

@ -17,16 +17,25 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from pytest import fixture
from twisted.words.protocols.jabber import jid
from twisted.words.xish import domish from twisted.words.xish import domish
from libervia.backend.plugins.plugin_xep_0338 import NS_JINGLE_GROUPING, XEP_0338 from libervia.backend.plugins.plugin_xep_0166 import XEP_0166, NS_JINGLE
from libervia.backend.plugins.plugin_xep_0338 import NS_JINGLE_GROUPING
from libervia.backend.tools.xml_tools import parse from libervia.backend.tools.xml_tools import parse
@fixture(autouse=True)
def no_application_register(monkeypatch):
"""Do not register the application in XEP-0166"""
monkeypatch.setattr(XEP_0166, "register_application", lambda *a, **kw: None)
class TestXEP0338: class TestXEP0338:
def test_parse_sdp(self, host): def test_parse_sdp(self, host):
"""'group' attribute in SDP is correctly parsed""" """'group' attribute in SDP is correctly parsed"""
xep_0338 = XEP_0338(host) xep_0338 = host.plugins["XEP-0338"]
call_data = {} call_data = {}
metadata = {} metadata = {}
@ -52,7 +61,7 @@ class TestXEP0338:
def test_generate_sdp(self, host): def test_generate_sdp(self, host):
"""'group' attribute in SDP is correctly generated""" """'group' attribute in SDP is correctly generated"""
xep_0338 = XEP_0338(host) xep_0338 = host.plugins["XEP-0338"]
session = {"metadata": {"group": {"BUNDLE": ["audio", "video"]}}} session = {"metadata": {"group": {"BUNDLE": ["audio", "video"]}}}
sdp_lines = [] sdp_lines = []
@ -64,7 +73,7 @@ class TestXEP0338:
def test_group_building(self, host, client): def test_group_building(self, host, client):
"""<group> element are built from session in session init trigger""" """<group> element are built from session in session init trigger"""
xep_0338 = XEP_0338(host) xep_0338 = host.plugins["XEP-0338"]
session = { session = {
"contents": {"audio": {}}, "contents": {"audio": {}},
@ -92,7 +101,7 @@ class TestXEP0338:
async def test_group_parsing(self, host, client): async def test_group_parsing(self, host, client):
"""<group> elements are correctly parsed in jingle_handler trigger""" """<group> elements are correctly parsed in jingle_handler trigger"""
xep_0338 = XEP_0338(host) xep_0338 = host.plugins["XEP-0338"]
action = xep_0338._j.A_SESSION_INITIATE action = xep_0338._j.A_SESSION_INITIATE
session = { session = {
@ -138,3 +147,159 @@ class TestXEP0338:
for content_elt in group_elt.elements(NS_JINGLE_GROUPING, "content") for content_elt in group_elt.elements(NS_JINGLE_GROUPING, "content")
] ]
assert content_names == ["audio", "video"] assert content_names == ["audio", "video"]
async def test_content_add_bundle_group(self, host, client, client_IQ):
"""A BUNDLE group is declared with all the contents sharing the transport"""
xep_0167 = host.plugins["XEP-0167"]
client.IQ = client_IQ()
session_id = "123"
session = {
"id": session_id,
"local_jid": client.jid,
"peer_jid": jid.JID("room@conference.test.example"),
"role": "initiator",
# an existing content already using the transport (e.g. a single transport
# shared by all the streams of the session)
"contents": {
"0-0": {
"application_data": {"media": "audio"},
"transport_data": {
"local_ice_data": {"ufrag": "uf1", "pwd": "pw1", "candidates": []}
},
}
},
}
client.jingle_sessions = {session_id: session}
lines = [
"v=0",
"o=- 0 0 IN IP4 127.0.0.1",
"s=-",
"t=0 0",
"m=audio 9 UDP/TLS/RTP/SAVPF 111",
"c=IN IP4 0.0.0.0",
"a=rtcp-mux",
"a=mid:1-0",
"a=sendonly",
"a=rtpmap:111 opus/48000/2",
"a=ice-ufrag:uf1",
"a=ice-pwd:pw1",
"m=video 9 UDP/TLS/RTP/SAVPF 96",
"c=IN IP4 0.0.0.0",
"a=rtcp-mux",
"a=mid:1-1",
"a=sendonly",
"a=rtpmap:96 VP8/90000",
"a=ice-ufrag:uf1",
"a=ice-pwd:pw1",
]
added = await xep_0167.call_update(
client, session_id, {"sdp": "\r\n".join(lines)}
)
assert added == ["1-0", "1-1"]
# the BUNDLE group is declared for the added contents
iq_elt = client.IQ.sent_iqs[0]
jingle_elt = list(iq_elt.elements(NS_JINGLE, "jingle"))[0]
group_elts = list(jingle_elt.elements(NS_JINGLE_GROUPING, "group"))
assert len(group_elts) == 1
group_elt = group_elts[0]
assert group_elt["semantics"] == "BUNDLE"
content_names = [
c["name"] for c in group_elt.elements(NS_JINGLE_GROUPING, "content")
]
# the existing content sharing the transport is included in the group
assert content_names == ["0-0", "1-0", "1-1"]
def test_sdp_bundle_group(self, host, client):
"""The stream answer SDP uses the stream's own BUNDLE group, not the uplink's"""
xep_0167 = host.plugins["XEP-0167"]
session = {
"id": "123",
"local_jid": client.jid,
"role": "responder",
"peer_metadata": {"group": {"BUNDLE": ["video0", "audio1"]}},
"contents": {
"1-0": {
"application_data": {
"media": "audio",
"peer_data": {
"payload_types": {111: {"name": "opus", "clockrate": 48000}}
},
},
"transport_data": {
"peer_ice_data": {"ufrag": "uf", "pwd": "pw", "candidates": []}
},
"senders": "responder",
},
"1-1": {
"application_data": {
"media": "video",
"peer_data": {
"payload_types": {96: {"name": "VP8", "clockrate": 90000}}
},
},
"transport_data": {
"peer_ice_data": {"ufrag": "uf", "pwd": "pw", "candidates": []}
},
"senders": "responder",
},
},
}
sdps = []
session["content_accept_cb"] = lambda *a: sdps.append(a[2])
xep_0167.on_content_change_trigger(
client, XEP_0166.A_CONTENT_ACCEPT, session, ["1-0", "1-1"]
)
assert len(sdps) == 1
assert "a=group:BUNDLE 1-0 1-1" in sdps[0]
assert "video0" not in sdps[0]
def test_content_change_sdp_no_bundle_group(self, host, client):
"""Accepted contents with different transports are not bundled"""
xep_0167 = host.plugins["XEP-0167"]
session = {
"id": "123",
"local_jid": client.jid,
"role": "responder",
"peer_metadata": {"group": {"BUNDLE": ["video0", "audio1"]}},
"contents": {
"1-0": {
"application_data": {
"media": "audio",
"peer_data": {
"payload_types": {111: {"name": "opus", "clockrate": 48000}}
},
},
"transport_data": {
"peer_ice_data": {
"ufrag": "uf1",
"pwd": "pw1",
"candidates": [],
}
},
"senders": "responder",
},
"1-1": {
"application_data": {
"media": "video",
"peer_data": {
"payload_types": {96: {"name": "VP8", "clockrate": 90000}}
},
},
"transport_data": {
"peer_ice_data": {
"ufrag": "uf2",
"pwd": "pw2",
"candidates": [],
}
},
"senders": "responder",
},
},
}
sdps = []
session["content_accept_cb"] = lambda *a: sdps.append(a[2])
xep_0167.on_content_change_trigger(
client, XEP_0166.A_CONTENT_ACCEPT, session, ["1-0", "1-1"]
)
assert len(sdps) == 1
assert "a=group:" not in sdps[0]