Mercurial > libervia-web
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))