diff libervia/web/pages/calls/_browser/__init__.py @ 1600:0a4433a343a3

browser (calls): implement WebRTC file sharing: - Send file through WebRTC when the new `file` button is used during a call. - Show a confirmation dialog and download file sent by WebRTC. rel 442
author Goffi <goffi@goffi.org>
date Sat, 06 Apr 2024 13:06:17 +0200
parents d282dbdd5ffd
children 6feac4a25e60
line wrap: on
line diff
--- a/libervia/web/pages/calls/_browser/__init__.py	Tue Mar 05 16:40:25 2024 +0100
+++ b/libervia/web/pages/calls/_browser/__init__.py	Sat Apr 06 13:06:17 2024 +0200
@@ -4,6 +4,7 @@
 from browser import aio, console as log, document, window
 from cache import cache
 import dialog
+from javascript import JSObject
 from jid import JID
 from jid_search import JidSearch
 import loading
@@ -42,9 +43,11 @@
             on_video_devices=self.on_video_devices,
             on_reset_cb=self.on_reset_cb,
         )
+        # mapping of file sending
+        self.files_webrtc: list[dict] = []
         self.mode = "search"
         self._status = None
-        self._callee = None
+        self._callee: JID|None = None
         self.contacts_elt = document["contacts"]
         self.search_container_elt = document["search_container"]
         self.call_container_elt = document["call_container"]
@@ -82,6 +85,8 @@
         else:
             self.share_desktop_col_elt.classList.add("is-hidden")
         document["switch_camera_btn"].bind("click", self.on_switch_camera)
+        document["send_file_btn"].bind("click", self.on_send_file)
+        document["send_file_input"].bind("change", self._on_send_input_change)
 
         # search
         self.search_elt = document["search"]
@@ -185,13 +190,128 @@
         @param profile: Profile associated with the action
         """
         action_data = json.loads(action_data_s)
-        if action_data.get("type") != "call":
+        if action_data.get("type") == "confirm" and action_data.get("subtype") == "file":
+            aio.run(self.on_file_preflight(action_data, action_id))
+        elif action_data.get("type") == "file":
+            aio.run(self.on_file_proposal(action_data, action_id))
+        elif action_data.get("type") != "call":
             return
-        aio.run(self.on_action_new(action_data, action_id))
+        else:
+            aio.run(self.on_action_new(action_data, action_id))
+
+    def get_human_size(self, size: int|float) -> str:
+        """Return size in human-friendly size using SI units"""
+        units = ["o","Kio","Mio","Gio"]
+        for idx, unit in enumerate(units):
+            if size < 1024.0 or idx == len(units)-1:
+                return f"{size:.2f}{unit}"
+            size /= 1024.0
+        raise Exception("Internal Error: this line should never be reached.")
+
+    async def request_file_permission(self, action_data: dict) -> bool:
+        """Request permission to download a file."""
+        peer_jid = JID(action_data["from_jid"]).bare
+        await cache.fill_identities([peer_jid])
+        identity = cache.identities[peer_jid]
+        peer_name = identity["nicknames"][0]
+
+        file_data = action_data.get("file_data", {})
+
+        file_name = file_data.get('name')
+        file_size = file_data.get('size')
+
+        if file_name:
+            file_name_msg = 'wants to send you the file "{file_name}"'.format(
+                file_name=file_name
+            )
+        else:
+            file_name_msg = 'wants to send you an unnamed file'
+
+        if file_size is not None:
+            file_size_msg = "which has a size of {file_size_human}".format(
+                file_size_human=self.get_human_size(file_size)
+            )
+        else:
+            file_size_msg = "which has an unknown size"
+
+        file_description = file_data.get('desc')
+        if file_description:
+            description_msg = " Description: {}.".format(file_description)
+        else:
+            description_msg = ""
+
+        file_data = action_data.get("file_data", {})
+
+        file_accept_dlg = dialog.Confirm(
+            "{peer_name} ({peer_jid}) {file_name_msg} {file_size_msg}.{description_msg} Do you "
+            "accept?".format(
+                peer_name=peer_name,
+                peer_jid=peer_jid,
+                file_name_msg=file_name_msg,
+                file_size_msg=file_size_msg,
+                description_msg=description_msg
+            ),
+            ok_label="Download",
+            cancel_label="Reject"
+        )
+        return await file_accept_dlg.ashow()
+
+    async def on_file_preflight(self, action_data: dict, action_id: str) -> None:
+        """Handle a file preflight (proposal made to all devices)."""
+        # FIXME: temporarily done in call page, will be moved to notifications handler to
+        #   make it work anywhere.
+        accepted = await self.request_file_permission(action_data)
+
+        await bridge.action_launch(
+            action_id, json.dumps({"answer": str(accepted).lower()})
+        )
+
+    async def on_file_proposal(self, action_data: dict, action_id: str) -> None:
+        """Handle a file proposal.
+
+        This is a proposal made specifically to this device, a opposed to
+        ``on_file_preflight``. File may already have been accepted during preflight.
+        """
+        # FIXME: as for on_file_preflight, this will be moved to notification handler.
+        if not action_data.get("webrtc", False):
+            peer_jid = JID(action_data["from_jid"]).bare
+            # We try to do a not-too-technical warning about webrtc not being supported.
+            dialog.notification.show(
+                f"A file sending from {peer_jid} can't be accepted because it is not "
+                "compatible with web browser direct transfer (WebRTC).",
+                level="warning",
+            )
+            # We don't explicitly refuse the file proposal, because it may be accepted and
+            # supported by other frontends.
+            # TODO: Check if any other frontend is connected for this profile, and refuse
+            # the file if none is.
+            return
+        if action_data.get("file_accepted", False):
+            # File proposal has already been accepted in preflight.
+            accepted = True
+        else:
+            accepted = await self.request_file_permission(action_data)
+
+        if accepted:
+            sid = action_data["session_id"]
+            webrtc = WebRTC(
+                file_only=True,
+                extra_data={"file_data": action_data.get("file_data", {})}
+            )
+            webrtc.sid = sid
+            self.files_webrtc.append({
+                "webrtc": webrtc,
+            })
+
+        await bridge.action_launch(
+            action_id, json.dumps({"answer": str(accepted).lower()})
+        )
 
     async def on_action_new(self, action_data: dict, action_id: str) -> None:
         peer_jid = JID(action_data["from_jid"]).bare
-        log.info(f"{peer_jid} wants to start a call ({action_data['sub_type']})")
+        call_type = action_data["sub_type"]
+        call_emoji = "📹" if call_type == VIDEO else "📞"
+        log.info(f"{peer_jid} wants to start a call ({call_type}).")
         if self.sid is not None:
             log.warning(
                 f"already in a call ({self.sid}), can't receive a new call from "
@@ -210,14 +330,30 @@
         # and ask user if we take the call
         try:
             self.incoming_call_dialog_elt = dialog.Confirm(
-                f"{peer_name} is calling you.", ok_label="Answer", cancel_label="Reject"
+                f"{peer_name} is calling you ({call_emoji}{call_type}).", ok_label="Answer", cancel_label="Reject"
             )
             accepted = await self.incoming_call_dialog_elt.ashow()
-        except dialog.CancelError:
+        except dialog.CancelError as e:
             log.info("Call has been cancelled")
             self.incoming_call_dialog_elt = None
             self.sid = None
-            dialog.notification.show(f"{peer_name} has cancelled the call", level="info")
+            match e.reason:
+                case "busy":
+                    dialog.notification.show(
+                        f"{peer_name} can't answer your call",
+                        level="info",
+                    )
+                case "taken_by_other_device":
+                    device = e.text
+                    dialog.notification.show(
+                        f"The call has been taken on another device ({device}).",
+                        level="info",
+                    )
+                case _:
+                    dialog.notification.show(
+                        f"{peer_name} has cancelled the call",
+                        level="info"
+                    )
             return
 
         self.incoming_call_dialog_elt = None
@@ -230,6 +366,7 @@
             log.debug(f"Call SID: {sid}")
 
             # Answer the call
+            self.call_mode = call_type
             self.set_avatar(peer_jid)
             self.status = "connecting"
             self.switch_mode("call")
@@ -281,11 +418,18 @@
             sdp: Session Description Protocol data
         @param profile: Profile associated
         """
-        if self.sid != session_id:
-            log.debug(
-                f"Call ignored due to different session ID ({self.sid=} {session_id=})"
-            )
-            return
+        if self.sid == session_id:
+            webrtc = self.webrtc
+        else:
+            for file_webrtc in self.files_webrtc:
+                webrtc = file_webrtc["webrtc"]
+                if webrtc.sid == session_id:
+                    break
+            else:
+                log.debug(
+                    f"Call ignored due to different session ID ({self.sid=} {session_id=})"
+                )
+                return
         try:
             role = setup_data["role"]
             sdp = setup_data["sdp"]
@@ -295,9 +439,9 @@
             )
             return
         if role == "initiator":
-            await self.webrtc.accept_call(session_id, sdp, profile)
+            await webrtc.accept_call(session_id, sdp, profile)
         elif role == "responder":
-            await self.webrtc.answer_call(session_id, sdp, profile)
+            await webrtc.answer_call(session_id, sdp, profile)
         else:
             dialog.notification.show(
                 f"Invalid role received during setup: {setup_data}", level="error"
@@ -358,20 +502,15 @@
         # if there is any ringing, we stop it
         self.audio_player_elt.pause()
         self.audio_player_elt.currentTime = 0
+        reason = data.get("reason", "")
+        text = data.get("text", "")
 
         if self.incoming_call_dialog_elt is not None:
-            self.incoming_call_dialog_elt.cancel()
+            self.incoming_call_dialog_elt.cancel(reason, text)
             self.incoming_call_dialog_elt = None
 
         self.switch_mode("search")
 
-        if data.get("reason") == "busy":
-            assert self._callee is not None
-            peer_name = cache.identities[self._callee]["nicknames"][0]
-            dialog.notification.show(
-                f"{peer_name} can't answer your call",
-                level="info",
-            )
 
         await self.webrtc.end_call()
 
@@ -540,6 +679,24 @@
     def on_switch_camera(self, __) -> None:
         aio.run(self.webrtc.switch_camera())
 
+    def on_send_file(self, __) -> None:
+        document["send_file_input"].click()
+
+    def _on_send_input_change(self, evt) -> None:
+        aio.run(self.on_send_input_change(evt))
+
+    async def on_send_input_change(self, evt) -> None:
+        assert self._callee is not None
+        files = evt.currentTarget.files
+        for file in files:
+            webrtc = WebRTC(file_only=True)
+            self.files_webrtc.append({
+                "file": file,
+                "webrtc": webrtc
+            })
+            await webrtc.send_file(self._callee, file)
+
+
     def _on_entity_click(self, item: dict) -> None:
         aio.run(self.on_entity_click(item))