changeset 514:d78728d7fd6a

plugin wid calls, core: implements WebRTC DataChannel file transfer: - Add a new "file" icon in call UI to send a file via WebRTC. - Handle new preflight mechanism, and WebRTC file transfer. - Native file chooser handling has been moved to new `core.file_chooser` module, and now supports "save" and "dir" modes (based on `plyer`). rel 442
author Goffi <goffi@goffi.org>
date Sat, 06 Apr 2024 13:37:27 +0200
parents 0fdf3e59aaad
children 2ff26b4273df
files libervia/desktop_kivy/core/cagou_main.py libervia/desktop_kivy/core/file_chooser.py libervia/desktop_kivy/plugins/plugin_transfer_file.py libervia/desktop_kivy/plugins/plugin_wid_calls.kv libervia/desktop_kivy/plugins/plugin_wid_calls.py
diffstat 5 files changed, 287 insertions(+), 35 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/desktop_kivy/core/cagou_main.py	Thu Jan 18 23:46:31 2024 +0100
+++ b/libervia/desktop_kivy/core/cagou_main.py	Sat Apr 06 13:37:27 2024 +0200
@@ -22,19 +22,23 @@
 import glob
 import sys
 from pathlib import Path
+from typing import Callable
 from urllib import parse as urlparse
 from functools import partial
 from libervia.backend.core.i18n import _
+
+# `do_hack` msut be run before any Kivy import!
 from . import kivy_hack
 kivy_hack.do_hack()
 from .constants import Const as C
 from libervia.backend.core import log as logging
 from libervia.backend.core import exceptions
+from libervia.desktop_kivy.core.file_chooser import FileChooser
 from libervia.frontends.quick_frontend.quick_app import QuickApp
 from libervia.frontends.quick_frontend import quick_widgets
 from libervia.frontends.quick_frontend import quick_chat
 from libervia.frontends.quick_frontend import quick_utils
-from libervia.frontends.tools import jid
+from libervia.frontends.tools import aio, jid
 from libervia.backend.tools import utils as libervia_utils
 from libervia.backend.tools import config
 from libervia.backend.tools.common import data_format
@@ -995,6 +999,97 @@
 
     ## bridge handlers ##
 
+    async def _on_webrtc_file(
+        self,
+        action_data: dict,
+        action_id: str|None,
+        profile: str
+    ) -> None:
+        assert action_id is not None
+        try:
+            xmlui_data = action_data["xmlui"]
+        except KeyError:
+            raise exceptions.InternalError("Missing XMLUI in file action.")
+
+        try:
+            from_jid = jid.JID(action_data["from_jid"])
+        except KeyError:
+            raise exceptions.InternalError(
+                f"Missing 'from_jid' key: {action_data!r}"
+            )
+        from libervia.frontends.tools.webrtc_file import WebRTCFileReceiver
+        confirm_msg = WebRTCFileReceiver.format_confirm_msg(action_data, from_jid)
+
+        file_accepted = action_data.get("file_accepted", False)
+        if file_accepted:
+            accepted = True
+        else:
+            accepted = await self.ask_confirmation(confirm_msg, _("File Request"))
+
+        xmlui_data = {"answer": C.bool_const(accepted)}
+        if accepted:
+            file_data = action_data.get("file_data") or {}
+            file_name = file_data.get("name", "received_file")
+            dest_path_s = await FileChooser.a_open(
+                mode="save",
+                title=_("Please select the destination for file {file_name!r}.").format(
+                    file_name=file_name
+                ),
+                # FIXME: It doesn't seem to be a way to specify destination file name,
+                # ``path`` doesn't work this way, at least on Linux/KDE.
+                default_path=f"./{file_name}"
+            )
+            if dest_path_s is None:
+                accepted = False
+            else:
+                dest_path = Path(dest_path_s)
+                try:
+                    session_id = action_data["session_id"]
+                except KeyError:
+                    raise exceptions.InternalError("'session_id' is missing.")
+                file_receiver = WebRTCFileReceiver(
+                    self.a_bridge,
+                    profile,
+                    on_close_cb=lambda: self.add_note(
+                        _("File Received"),
+                        _('The file "{file_name}" has been successfuly received.').format(
+                            file_name = file_data.get("name", "")
+                        )
+                    )
+                )
+                await file_receiver.receive_file_webrtc(
+                    from_jid,
+                    session_id,
+                    dest_path,
+                    file_data
+                )
+
+        await self.a_bridge.action_launch(
+            action_id, data_format.serialise(xmlui_data), profile_key=profile
+        )
+
+    def action_manager(
+        self,
+        action_data: dict,
+        callback: Callable|None = None,
+        ui_show_cb: Callable|None = None,
+        user_action: bool = True,
+        action_id: str|None = None,
+        progress_cb: Callable|None = None,
+        progress_eb: Callable|None = None,
+        profile: str = C.PROF_KEY_NONE
+    ) -> None:
+        if (
+            action_data.get("type") == C.META_TYPE_FILE
+            and action_data.get("webrtc", False)
+        ):
+            aio.run_async(self._on_webrtc_file(action_data, action_id, profile))
+        else:
+            super().action_manager(
+                action_data, callback, ui_show_cb, user_action, action_id, progress_cb,
+                progress_eb, profile
+            )
+
     def otr_state_handler(self, state, dest_jid, profile):
         """OTR state has changed for on destinee"""
         # XXX: this method could be in QuickApp but it's here as
@@ -1101,7 +1196,24 @@
             cb(*args, **kwargs)
         return callback
 
-    def show_dialog(self, message, title, type="info", answer_cb=None, answer_data=None):
+    def show_dialog(
+        self,
+        message: str,
+        title: str,
+        type: str = "info",
+        answer_cb: Callable|None = None,
+        answer_data: dict|None = None
+    ):
+        """Show a dialog to the user.
+
+        @param message: The main text of the dialog.
+        @param title: Title of the dialog.
+        @param type: Type of dialog (info, warning, error, yes/no).
+        @param answer_cb: A callback that will be called when the user answers to the dialog.
+          You can pass an asynchronous function as well.
+        @param answer_data: Additional data for the dialog.
+
+        """
         if type in ('info', 'warning', 'error'):
             self.add_note(title, message, type)
         elif type == "yes/no":
@@ -1118,6 +1230,47 @@
         else:
             log.warning(_("unknown dialog type: {dialog_type}").format(dialog_type=type))
 
+    async def a_show_dialog(
+        self,
+        message: str,
+        title: str,
+        type: str = "info",
+        answer_data: dict|None = None
+    ) -> bool|None:
+        """Shows a dialog asynchronously and returns the user's response for 'yes/no' dialogs.
+
+        This method wraps the synchronous ``show_dialog`` method to work in an
+        asynchronous context.
+        It is specifically useful for 'yes/no' type dialogs, returning True for 'yes' and
+        False for 'no'. For other types, it returns None immediately after showing the
+        dialog.
+
+        See [show_dialog] for params.
+        @return: True if the user clicked 'yes', False if 'no', and None for other dialog types.
+        """
+        future = asyncio.Future()
+
+        def answer_cb(answer: bool, data: dict):
+            if not future.done():
+                future.set_result(answer)
+
+        if type == "yes/no":
+            self.show_dialog(message, title, type, answer_cb, answer_data)
+            return await future
+        else:
+            self.show_dialog(message, title, type)
+            return None
+
+    async def ask_confirmation(
+        self,
+        message: str,
+        title: str,
+        answer_data: dict|None = None
+    ) -> bool:
+        ret = await self.a_show_dialog(message, title, "yes/no", answer_data)
+        assert ret is bool
+        return ret
+
     def share(self, media_type, data):
         share_wid = ShareWidget(media_type=media_type, data=data)
         try:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/file_chooser.py	Sat Apr 06 13:37:27 2024 +0200
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+
+# Libervia Desktop-Kivy
+# Copyright (C) 2016-2024 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/>.
+
+import asyncio
+import threading
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+
+from kivy import properties
+from kivy.clock import Clock
+from kivy.event import EventDispatcher
+from plyer import filechooser, storagepath
+
+
+class FileChooser(EventDispatcher):
+    callback = properties.ObjectProperty()
+    cancel_cb = properties.ObjectProperty()
+    native_filechooser = True
+    default_path = properties.StringProperty(storagepath.get_home_dir())
+    mode = properties.OptionProperty("open", options=["open", "save", "dir"])
+    title = properties.StringProperty(_("Please select a file to upload"))
+
+    def open(self):
+        """Open the file selection dialog in a separate thread"""
+        thread = threading.Thread(target=self._native_file_chooser)
+        thread.start()
+
+    @classmethod
+    async def a_open(cls, **kwargs) -> str | None:
+        """Open the file selection dialog asynchronously
+
+        @return: The path of the selected file
+            None if the dialog has been cancelled.
+        """
+        future = asyncio.Future()
+
+        def on_success(file_path):
+            if not future.done():
+                future.set_result(file_path)
+
+        def on_cancel(__):
+            if not future.done():
+                future.set_result(None)
+
+        file_chooser = cls(
+            **kwargs,
+            callback=on_success,
+            cancel_cb=on_cancel
+        )
+
+        file_chooser.open()
+
+        return await future
+
+    def _native_file_chooser(self, *args, **kwargs):
+        match self.mode:
+            case "open":
+                method = filechooser.open_file
+            case "save":
+                method = filechooser.save_file
+            case "dir":
+                method = filechooser.choose_dir
+            case _:
+                raise exceptions.InternalError("Should never be reached.")
+        files = method(
+            title=self.title, path=self.default_path, multiple=False, preview=True
+        )
+        # we want to leave the thread when calling on_files, so we use Clock
+        Clock.schedule_once(lambda *args: self.on_files(files=files), 0)
+
+    def on_files(self, files):
+        if files:
+            self.callback(files[0])
+        else:
+            self.cancel_cb(self)
--- a/libervia/desktop_kivy/plugins/plugin_transfer_file.py	Thu Jan 18 23:46:31 2024 +0100
+++ b/libervia/desktop_kivy/plugins/plugin_transfer_file.py	Sat Apr 06 13:37:27 2024 +0200
@@ -1,7 +1,7 @@
 #!/usr/bin/env python3
 
 
-#Libervia Desktop-Kivy
+# Libervia Desktop-Kivy
 # Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org)
 
 # This program is free software: you can redistribute it and/or modify
@@ -17,15 +17,15 @@
 # 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/>.
 
-import threading
+from functools import partial
 import sys
-from functools import partial
+
 from libervia.backend.core import log as logging
 from libervia.backend.core.i18n import _
-from kivy.uix.boxlayout import BoxLayout
+
 from kivy import properties
-from kivy.clock import Clock
-from plyer import filechooser, storagepath
+from kivy.uix.boxlayout import BoxLayout
+from libervia.desktop_kivy.core import file_chooser
 
 log = logging.getLogger(__name__)
 
@@ -44,38 +44,23 @@
     default_path = properties.StringProperty()
 
 
-class FileTransmitter(BoxLayout):
-    callback = properties.ObjectProperty()
-    cancel_cb = properties.ObjectProperty()
-    native_filechooser = True
-    default_path = storagepath.get_home_dir()
+class FileTransmitter(BoxLayout, file_chooser.FileChooser):
+    """Widget to transmit files"""
 
     def __init__(self, *args, **kwargs):
-        if sys.platform == 'android':
+        if sys.platform == "android":
             self.native_filechooser = False
             self.default_path = storagepath.get_downloads_dir()
 
-        super(FileTransmitter, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
         if self.native_filechooser:
-            thread = threading.Thread(target=self._native_file_chooser)
-            thread.start()
+            self.open()
         else:
-            self.add_widget(FileChooserBox(default_path = self.default_path,
-                                           callback=self.on_files,
-                                           cancel_cb=partial(self.cancel_cb, self)))
-
-    def _native_file_chooser(self, *args, **kwargs):
-        title=_("Please select a file to upload")
-        files = filechooser.open_file(title=title,
-                                      path=self.default_path,
-                                      multiple=False,
-                                      preview=True)
-        # we want to leave the thread when calling on_files, so we use Clock
-        Clock.schedule_once(lambda *args: self.on_files(files=files), 0)
-
-    def on_files(self, files):
-        if files:
-            self.callback(files[0])
-        else:
-            self.cancel_cb(self)
+            self.add_widget(
+                FileChooserBox(
+                    default_path=self.default_path,
+                    callback=self.on_files,
+                    cancel_cb=partial(self.cancel_cb, self),
+                )
+            )
--- a/libervia/desktop_kivy/plugins/plugin_wid_calls.kv	Thu Jan 18 23:46:31 2024 +0100
+++ b/libervia/desktop_kivy/plugins/plugin_wid_calls.kv	Sat Apr 06 13:37:27 2024 +0200
@@ -156,6 +156,11 @@
                                 points: [self.x + dp(10), self.y + dp(10), self.right - dp(10), self.top - dp(10)]
                                 width: 2
                                 cap: "round"
+                    CallControlButton:
+                        symbol: "doc"
+                        color: 0, 0, 0, 1
+                        background_color: (1, 1, 1, 1)
+                        on_press: root.on_file_btn_press()
 
                     CallControlButton:
                         symbol: "phone"
--- a/libervia/desktop_kivy/plugins/plugin_wid_calls.py	Thu Jan 18 23:46:31 2024 +0100
+++ b/libervia/desktop_kivy/plugins/plugin_wid_calls.py	Sat Apr 06 13:37:27 2024 +0200
@@ -6,6 +6,7 @@
 # from gi.repository import GLib
 from gi.repository import GObject, Gst, GstWebRTC, GstSdp
 from kivy.metrics import dp
+from libervia.desktop_kivy.core.file_chooser import FileChooser
 
 try:
     from gi.overrides import Gst as _
@@ -38,6 +39,7 @@
 from libervia.backend.tools.common import data_format
 from libervia.frontends.quick_frontend import quick_widgets
 from libervia.frontends.tools import aio, display_servers, jid, webrtc
+from libervia.frontends.tools.webrtc_file import WebRTCFileSender
 
 from libervia.desktop_kivy import G
 
@@ -431,6 +433,22 @@
     def on_desktop_sharing(self, instance, active: bool) -> None:
         self.webrtc.desktop_sharing = active
 
+    def on_file_btn_press(self) -> None:
+        aio.run_async(self.on_file_press())
+
+    async def on_file_press(self):
+        callee = self.webrtc.callee
+        if callee is None:
+            return
+        file_to_send = await FileChooser.a_open()
+        if file_to_send is None:
+            return
+        file_sender = WebRTCFileSender(
+            G.host.a_bridge,
+            self.profile
+        )
+        await file_sender.send_file_webrtc(file_to_send, self.webrtc.callee)
+
     def on_fullscreen(self, instance, fullscreen: bool) -> None:
         if fullscreen:
             G.host.app.show_head_widget(False, animation=False)