diff libervia/backend/plugins/plugin_xep_0167/__init__.py @ 4112:bc60875cb3b8

plugin XEP-0166, XEP-0167, XEP-0234, XEP-0353: call events management to prepare for UI: - XEP-0166: add `jingle_preflight` and `jingle_preflight_cancel` methods to prepare a jingle session, principally used by XEP-0353 to create and cancel a session - XEP-0167: preflight methods implementation, workflow split in more methods/signals to handle UI and call events (e.g.: retract or reject a call) - XEP-0234: implementation of preflight methods as they are now mandatory - XEP-0353: handle various events using the new preflight methods rel 423
author Goffi <goffi@goffi.org>
date Wed, 09 Aug 2023 00:07:37 +0200
parents 4b842c1fb686
children 0da563780ffc
line wrap: on
line diff
--- a/libervia/backend/plugins/plugin_xep_0167/__init__.py	Tue Aug 08 23:59:24 2023 +0200
+++ b/libervia/backend/plugins/plugin_xep_0167/__init__.py	Wed Aug 09 00:07:37 2023 +0200
@@ -17,7 +17,9 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from typing import Optional
+import uuid
 
+from twisted.internet import reactor
 from twisted.internet import defer
 from twisted.words.protocols.jabber import jid
 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
@@ -37,8 +39,8 @@
 from ..plugin_xep_0166 import BaseApplicationHandler
 from .constants import (
     NS_JINGLE_RTP,
+    NS_JINGLE_RTP_AUDIO,
     NS_JINGLE_RTP_INFO,
-    NS_JINGLE_RTP_AUDIO,
     NS_JINGLE_RTP_VIDEO,
 )
 
@@ -88,11 +90,11 @@
             async_=True,
         )
         host.bridge.add_method(
-            "call_end",
+            "call_answer_sdp",
             ".plugin",
             in_sign="sss",
             out_sign="",
-            method=self._call_end,
+            method=self._call_answer_sdp,
             async_=True,
         )
         host.bridge.add_method(
@@ -100,17 +102,32 @@
             ".plugin",
             in_sign="ssss",
             out_sign="",
-            method=self._call_start,
+            method=self._call_info,
         )
+        host.bridge.add_method(
+            "call_end",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._call_end,
+            async_=True,
+        )
+
+        # args: session_id, serialised setup data (dict with keys "role" and "sdp"),
+        #   profile
         host.bridge.add_signal(
-            "call_accepted", ".plugin", signature="sss"
-        )  # args: session_id, answer_sdp, profile
+            "call_setup", ".plugin", signature="sss"
+        )
+
+        # args: session_id, data, profile
         host.bridge.add_signal(
             "call_ended", ".plugin", signature="sss"
-        )  # args: session_id, data, profile
+        )
+
+        # args: session_id, info_type, extra, profile
         host.bridge.add_signal(
             "call_info", ".plugin", signature="ssss"
-        )  # args: session_id, info_type, extra, profile
+        )
 
     def get_handler(self, client):
         return XEP_0167_handler()
@@ -135,8 +152,26 @@
         client: SatXMPPEntity,
         peer_jid: jid.JID,
         call_data: dict,
-    ) -> None:
-        """Temporary method to test RTP session"""
+    ) -> str:
+        """Initiate a call session with the given peer.
+
+        @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.
+            The dict can have the following keys:
+                - sdp (str): SDP data for the call.
+                - metadata (dict): Additional metadata for the call (optional).
+            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.
+
+        @return: Session ID (SID) for the initiated call session.
+
+        @raises exceptions.DataError: If media data is invalid or duplicate content name
+            (mid) is found.
+        """
         contents = []
         metadata = call_data.get("metadata") or {}
 
@@ -201,14 +236,35 @@
                 contents.append(content)
         if not contents:
             raise exceptions.DataError("no valid media data found: {call_data}")
-        return await self._j.initiate(
-            client,
-            peer_jid,
-            contents,
-            call_type=call_type,
-            metadata=metadata,
-            peer_metadata={},
+        sid = str(uuid.uuid4())
+        defer.ensureDeferred(
+            self._j.initiate(
+                client,
+                peer_jid,
+                contents,
+                sid=sid,
+                call_type=call_type,
+                metadata=metadata,
+                peer_metadata={},
+            )
         )
+        return sid
+
+    def _call_answer_sdp(
+        self,
+        session_id: str,
+        answer_sdp: str,
+        profile: str
+    ) -> None:
+        client = self.host.get_client(profile)
+        session = self._j.get_session(client, session_id)
+        try:
+            answer_sdp_d = session.pop("answer_sdp_d")
+        except KeyError:
+            raise exceptions.NotFound(
+                f"No answer SDP expected for session {session_id!r}"
+            )
+        answer_sdp_d.callback(answer_sdp)
 
     def _call_end(
         self,
@@ -240,6 +296,123 @@
 
     # jingle callbacks
 
+    async def confirm_incoming_call(
+        self,
+        client: SatXMPPEntity,
+        session: dict,
+        call_type: str
+    ) -> bool:
+        """Prompt the user for a call confirmation.
+
+        @param client: The client entity.
+        @param session: The Jingle session.
+        @param media_type: Type of media (audio or video).
+
+        @return: True if the call has been accepted
+        """
+        peer_jid = session["peer_jid"]
+
+        session["call_type"] = call_type
+        cancellable_deferred = session.setdefault("cancellable_deferred", [])
+
+        dialog_d = xml_tools.defer_dialog(
+            self.host,
+            _(CONFIRM).format(peer=peer_jid.userhost(), call_type=call_type),
+            _(CONFIRM_TITLE),
+            action_extra={
+                "session_id": session["id"],
+                "from_jid": peer_jid.full(),
+                "type": C.META_TYPE_CALL,
+                "sub_type": call_type,
+            },
+            security_limit=SECURITY_LIMIT,
+            profile=client.profile,
+        )
+
+        cancellable_deferred.append(dialog_d)
+
+        resp_data = await dialog_d
+
+        accepted = not resp_data.get("cancelled", False)
+
+        if accepted:
+            session["call_accepted"] = True
+
+        return accepted
+
+    async def jingle_preflight(
+        self,
+        client: SatXMPPEntity,
+        session: dict,
+        description_elt: domish.Element
+    ) -> None:
+        """Perform preflight checks for an incoming call session.
+
+        Check if the calls is audio only or audio/video, then, prompts the user for
+        confirmation.
+
+        @param client: The client instance.
+        @param session: Jingle session.
+        @param description_elt: The description element. It's parent attribute is used to
+            determine check siblings to see if it's an audio only or audio/video call.
+
+        @raises exceptions.CancelError: If the user doesn't accept the incoming call.
+        """
+        if session.get("call_accepted", False):
+            # the call is already accepted, nothing to do
+            return
+
+        parent_elt = description_elt.parent
+        assert parent_elt is not None
+
+        assert description_elt.parent is not None
+        for desc_elt in parent_elt.elements(NS_JINGLE_RTP, "description"):
+            if desc_elt.getAttribute("media") == "video":
+                call_type = C.META_SUBTYPE_CALL_VIDEO
+                break
+        else:
+            call_type = C.META_SUBTYPE_CALL_AUDIO
+
+        try:
+            accepted = await self.confirm_incoming_call(client, session, call_type)
+        except defer.CancelledError as e:
+            # raised when call is retracted before user has answered or rejected
+            self.host.bridge.call_ended(
+                session["id"],
+                data_format.serialise({"reason": "retracted"}),
+                client.profile
+            )
+            raise e
+
+        if not accepted:
+            raise exceptions.CancelError("User declined the incoming call.")
+
+    async def jingle_preflight_cancel(
+        self,
+        client: SatXMPPEntity,
+        session: dict,
+        cancel_error: exceptions.CancelError
+    ) -> None:
+        """The call has been rejected"""
+        # call_ended is use to send the signal only once even if there are audio and video
+        # contents
+        call_ended = session.get("call_ended", False)
+        if call_ended:
+            return
+        data = {
+            "reason": getattr(cancel_error, "reason", "cancelled")
+        }
+        text = getattr(cancel_error, "text", None)
+        if text:
+            data["text"] = text
+        self.host.bridge.call_ended(
+            session["id"],
+            data_format.serialise(data),
+            client.profile
+        )
+        session["call_ended"] = True
+
+
     def jingle_session_init(
         self,
         client: SatXMPPEntity,
@@ -275,42 +448,56 @@
         content_name: str,
         desc_elt: domish.Element,
     ) -> bool:
+        """Requests confirmation from the user for a Jingle session's incoming call.
+
+        This method checks the content type of the Jingle session (audio or video)
+        based on the session's contents. Confirmation is requested only for the first
+        content; subsequent contents are automatically accepted. This means, in practice,
+        that the call confirmation is prompted only once for both audio and video contents.
+
+        @param client: The client instance.
+        @param action: The action type associated with the Jingle session.
+        @param session: Jingle session.
+        @param content_name: Name of the content being checked.
+        @param desc_elt: The description element associated with the content.
+
+        @return: True if the call is accepted by the user, False otherwise.
+        """
         if content_name != next(iter(session["contents"])):
             # we request confirmation only for the first content, all others are
             # automatically accepted. In practice, that means that the call confirmation
             # is requested only once for audio and video contents.
             return True
-        peer_jid = session["peer_jid"]
 
-        if any(
-            c["desc_elt"].getAttribute("media") == "video"
-            for c in session["contents"].values()
-        ):
-            call_type = session["call_type"] = C.META_SUBTYPE_CALL_VIDEO
-        else:
-            call_type = session["call_type"] = C.META_SUBTYPE_CALL_AUDIO
+        if not session.get("call_accepted", False):
+            if any(
+                c["desc_elt"].getAttribute("media") == "video"
+                for c in session["contents"].values()
+            ):
+                call_type = session["call_type"] = C.META_SUBTYPE_CALL_VIDEO
+            else:
+                call_type = session["call_type"] = C.META_SUBTYPE_CALL_AUDIO
+
+            accepted = await self.confirm_incoming_call(client, session, call_type)
+            if not accepted:
+                return False
 
         sdp = mapping.generate_sdp_from_session(session)
+        session["answer_sdp_d"] = answer_sdp_d = defer.Deferred()
+        # we should have the answer long before 2 min
+        answer_sdp_d.addTimeout(2 * 60, reactor)
 
-        resp_data = await xml_tools.defer_dialog(
-            self.host,
-            _(CONFIRM).format(peer=peer_jid.userhost(), call_type=call_type),
-            _(CONFIRM_TITLE),
-            action_extra={
-                "session_id": session["id"],
-                "from_jid": peer_jid.full(),
-                "type": C.META_TYPE_CALL,
-                "sub_type": call_type,
+        self.host.bridge.call_setup(
+            session["id"],
+            data_format.serialise({
+                "role": session["role"],
                 "sdp": sdp,
-            },
-            security_limit=SECURITY_LIMIT,
-            profile=client.profile,
+            }),
+            client.profile
         )
 
-        if resp_data.get("cancelled", False):
-            return False
+        answer_sdp = await answer_sdp_d
 
-        answer_sdp = resp_data["sdp"]
         parsed_answer = mapping.parse_sdp(answer_sdp)
         session["peer_metadata"].update(parsed_answer["metadata"])
         for media in ("audio", "video"):
@@ -352,7 +539,14 @@
                 # we only send the signal for first content, as it means that the whole
                 # session is accepted
                 answer_sdp = mapping.generate_sdp_from_session(session)
-                self.host.bridge.call_accepted(session["id"], answer_sdp, client.profile)
+                self.host.bridge.call_setup(
+                    session["id"],
+                    data_format.serialise({
+                        "role": session["role"],
+                        "sdp": answer_sdp,
+                    }),
+                    client.profile
+                )
         else:
             log.warning(f"FIXME: unmanaged action {action}")
 
@@ -397,7 +591,6 @@
         extra = data_format.deserialise(extra_s)
         return self.send_info(client, session_id, info_type, extra)
 
-
     def send_info(
         self,
         client: SatXMPPEntity,
@@ -423,7 +616,15 @@
         content_name: str,
         reason_elt: domish.Element,
     ) -> None:
-        self.host.bridge.call_ended(session["id"], "", client.profile)
+        reason, text = self._j.parse_reason_elt(reason_elt)
+        data = {
+            "reason": reason
+        }
+        if text:
+            data["text"] = text
+        self.host.bridge.call_ended(
+            session["id"], data_format.serialise(data), client.profile
+        )
 
 
 @implementer(iwokkel.IDisco)