# HG changeset patch # User Goffi # Date 1712403447 -7200 # Node ID d78728d7fd6a758e67389fd45762f9ffad42868b # Parent 0fdf3e59aaadeb7e4f5726f949f2ba0ebc1a05da 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 diff -r 0fdf3e59aaad -r d78728d7fd6a libervia/desktop_kivy/core/cagou_main.py --- 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: diff -r 0fdf3e59aaad -r d78728d7fd6a libervia/desktop_kivy/core/file_chooser.py --- /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 . + +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) diff -r 0fdf3e59aaad -r d78728d7fd6a libervia/desktop_kivy/plugins/plugin_transfer_file.py --- 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 . -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), + ) + ) diff -r 0fdf3e59aaad -r d78728d7fd6a libervia/desktop_kivy/plugins/plugin_wid_calls.kv --- 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" diff -r 0fdf3e59aaad -r d78728d7fd6a libervia/desktop_kivy/plugins/plugin_wid_calls.py --- 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)