From bf3d614b84607fea828f4e945a4ae898257b43c4 Mon Sep 17 00:00:00 2001 From: Sander Sweers Date: Tue, 3 Dec 2024 18:07:09 +0100 Subject: [PATCH] WIP --- blueman/gui/DeviceSelectorDialog.py | 204 +++++++++++++++++++++++----- blueman/gui/DeviceSelectorWidget.py | 120 ---------------- blueman/gui/Makefile.am | 2 +- blueman/main/Sendto.py | 98 ++++++++++--- data/ui/Makefile.am | 2 + data/ui/sendto-device-dialog.ui | 167 +++++++++++++++++++++++ data/ui/sendto-rowbox.ui | 56 ++++++++ po/POTFILES.in | 1 - 8 files changed, 476 insertions(+), 174 deletions(-) delete mode 100644 blueman/gui/DeviceSelectorWidget.py create mode 100644 data/ui/sendto-device-dialog.ui create mode 100644 data/ui/sendto-rowbox.ui diff --git a/blueman/gui/DeviceSelectorDialog.py b/blueman/gui/DeviceSelectorDialog.py index 28babb445..af1af22b9 100644 --- a/blueman/gui/DeviceSelectorDialog.py +++ b/blueman/gui/DeviceSelectorDialog.py @@ -1,56 +1,188 @@ from gettext import gettext as _ +from typing import Any, cast, Optional, Tuple +import logging from blueman.bluez.Device import Device -from blueman.gui.DeviceList import DeviceList -from blueman.gui.DeviceSelectorWidget import DeviceSelectorWidget +from blueman.Functions import adapter_path_to_name from blueman.bluemantyping import ObjectPath +from blueman.main.Builder import Builder import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk +from gi.repository import Gtk, GObject -class DeviceSelectorDialog(Gtk.Dialog): - selection: tuple[ObjectPath, Device | None] | None +class DeviceRow(Gtk.ListBoxRow): + def __init__( + self, + device_path: ObjectPath, + adapter_path: ObjectPath, + device_icon: str = "blueman", + alias: str = _("Unnamed device"), + warning: bool = False + ) -> None: + super().__init__(visible=True) - def __init__(self, title: str = _("Select Device"), parent: Gtk.Container | None = None, discover: bool = True, - adapter_name: str | None = None) -> None: - super().__init__(title=title, name="DeviceSelectorDialog", parent=parent, icon_name="blueman", resizable=False) - self.add_buttons(_("_Cancel"), Gtk.ResponseType.REJECT, _("_OK"), Gtk.ResponseType.ACCEPT) + self.adapter_path = adapter_path + self.device_path = device_path - self.vbox.props.halign = Gtk.Align.CENTER - self.vbox.props.valign = Gtk.Align.CENTER - self.vbox.props.hexpand = True - self.vbox.props.vexpand = True - self.vbox.props.margin = 6 + builder = Builder("sendto-rowbox.ui") + self.__box = builder.get_widget("row_box", Gtk.Box) + self._row_icon = builder.get_widget("row_icon", Gtk.Image) + self._row_alias_label = builder.get_widget("row_alias", Gtk.Label) + self._row_warning = builder.get_widget("row_warn", Gtk.Image) - self.selector = DeviceSelectorWidget(adapter_name=adapter_name, visible=True) - self.vbox.pack_start(self.selector, True, True, 0) + self.add(self.__box) - selected_device = self.selector.List.get_selected_device() - if selected_device is not None: - self.selection = selected_device["Adapter"], selected_device + row_adapter_label = builder.get_widget("row_adapter_name", Gtk.Label) + adapter_name = adapter_path_to_name(adapter_path) + assert adapter_name is not None + row_adapter_label.set_markup(f"({adapter_name})") + + self.device_icon_name = device_icon + self.description = alias + self.warning = warning + + @property + def device_icon_name(self) -> str: + return self._row_icon.get_icon_name()[0] + + @device_icon_name.setter + def device_icon_name(self, icon_name: str) -> None: + self._row_icon.set_from_icon_name(icon_name, size=Gtk.IconSize.SMALL_TOOLBAR) + + @property + def description(self) -> str: + return self._row_alias_label.get_label() + + @description.setter + def description(self, text: str) -> None: + self._row_alias_label.set_label(text) + + @property + def warning(self) -> bool: + return self._row_warning.get_visible() + + @warning.setter + def warning(self, warning: bool) -> None: + self._row_warning.set_visible(warning) + + +class DeviceSelector: + selection: Optional[Tuple[ObjectPath, Optional[Device]]] + + def __init__(self, adapter_name: str = "any") -> None: + self._rows: dict[ObjectPath, DeviceRow] = {} + self._default_adapter_name = adapter_name + builder = Builder("sendto-device-dialog.ui") + self.dialog = builder.get_widget("select_device_dialog", Gtk.Dialog) + self._adapter_combo = builder.get_widget("adapter_combo", Gtk.ComboBoxText) + + self._discover_button = builder.get_widget("discover_button", Gtk.ToggleButton) + self._discover_spinner = builder.get_widget("discover_toggle_spinner", Gtk.Spinner) + self._discover_button.bind_property( + source_property="active", + target=self._discover_spinner, + target_property="active", + flags=GObject.BindingFlags.SYNC_CREATE + ) + + self._listbox = builder.get_widget("device_listbox", Gtk.ListBox) + self._listbox.set_filter_func(self.__list_filter_func, None) + self._listbox.connect("row-selected", self.__on_row_selected) + + self._adapter_combo.connect("changed", lambda _: self._listbox.invalidate_filter()) + + def add_adapter(self, object_path: ObjectPath) -> None: + name = adapter_path_to_name(object_path) + if name is None: + raise ValueError("Invalid adapter") + pos = int(name[-1]) + 1 + self._adapter_combo.insert(pos, name, name) + if name == self._default_adapter_name: + self._adapter_combo.set_active_id(name) + + def remove_adapter(self, object_path: ObjectPath) -> None: + name = adapter_path_to_name(object_path) + if name is None: + raise ValueError("Invalid adapter") + + for key in self._rows: + if self._rows[key].adapter_path == object_path: + row = self._rows.pop(key) + self._listbox.remove(row) + + if name == self._adapter_combo.get_active_id(): + self._adapter_combo.set_active_id(name) + + pos = int(name[-1]) + 1 + self._adapter_combo.remove(pos) + + def add_device(self, object_path: ObjectPath, show_warning: bool) -> None: + device = Device(obj_path=object_path) + row = DeviceRow( + device_path=object_path, + adapter_path=device["Adapter"], + device_icon=f"{device['Icon']}-symbolic", + alias=device["Alias"], + warning=show_warning + ) + + self._listbox.add(row) + self._rows[object_path] = row + + def remove_device(self, object_path: ObjectPath) -> None: + row = self._rows.pop(object_path) + self._listbox.remove(row) + + def update_row(self, object_path: ObjectPath, element: str, value: Any) -> None: + row = self._rows.get(object_path, None) + if row is None: + raise ValueError(f"Unknown device {object_path}") + + match element: + case "description": + row.description = value + case "warning": + row.warning = value + + def set_discovering(self, discovering: bool) -> None: + self._discover_button.set_active(discovering) + if discovering: + self._discover_button.set_label(_("Searching")) + # self._discover_spinner.start() else: - self.selection = None + self._discover_button.set_label(_("Search")) + # self._discover_spinner.stop() - self.selector.List.connect("device-selected", self.on_device_selected) - self.selector.List.connect("adapter-changed", self.on_adapter_changed) - if discover: - self.selector.List.discover_devices() + def __list_filter_func(self, row: Gtk.ListBoxRow, _: Any) -> bool: + row = cast(DeviceRow, row) + active_id = self._adapter_combo.get_active_id() + adapter_name = adapter_path_to_name(row.adapter_path) + if active_id == "any" or active_id == adapter_name: + return True + else: + return False - self.selector.List.connect("row-activated", self.on_row_activated) + def __on_row_selected(self, _listbox: Gtk.ListBox, row: Gtk.ListBoxRow | None) -> None: + if row is None: + self.__select_first_row() + return - def close(self) -> None: - self.selector.destroy() - super().close() + row = cast(DeviceRow, row) + logging.debug(f"{row.device_path}") + device = Device(obj_path=row.device_path) + self.selection = row.adapter_path, device - def on_row_activated(self, _treeview: Gtk.TreeView, _path: Gtk.TreePath, _view_column: Gtk.TreeViewColumn, - *_args: object) -> None: - self.response(Gtk.ResponseType.ACCEPT) + def __select_first_row(self) -> None: + for row in self._listbox.get_children(): + row = cast(DeviceRow, row) + if row.get_selectable(): + self._listbox.select_row(row) - def on_adapter_changed(self, _devlist: DeviceList, _adapter: str) -> None: - self.selection = None + def run(self) -> int: + return self.dialog.run() - def on_device_selected(self, devlist: DeviceList, device: Device | None, _tree_iter: Gtk.TreeIter) -> None: - assert devlist.Adapter is not None - self.selection = (devlist.Adapter.get_object_path(), device) + def close(self) -> None: + # FIXME implement a destroy method self.selector.destroy() + self.dialog.close() diff --git a/blueman/gui/DeviceSelectorWidget.py b/blueman/gui/DeviceSelectorWidget.py deleted file mode 100644 index 30f9937e1..000000000 --- a/blueman/gui/DeviceSelectorWidget.py +++ /dev/null @@ -1,120 +0,0 @@ -from gettext import gettext as _ -import os -import logging - -from blueman.bluez.Adapter import Adapter -from blueman.gui.DeviceSelectorList import DeviceSelectorList - -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk - - -class DeviceSelectorWidget(Gtk.Box): - def __init__(self, adapter_name: str | None = None, orientation: Gtk.Orientation = Gtk.Orientation.VERTICAL, - visible: bool = False) -> None: - - super().__init__(orientation=orientation, spacing=1, vexpand=True, - width_request=360, height_request=340, - name="DeviceSelectorWidget", visible=visible) - - self.List = DeviceSelectorList(adapter_name) - if self.List.Adapter is not None: - self.List.populate_devices() - - sw = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER, - vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, - shadow_type=Gtk.ShadowType.IN) - sw.add(self.List) - self.pack_start(sw, True, True, 0) - - # Disable overlay scrolling - if Gtk.get_minor_version() >= 16: - sw.props.overlay_scrolling = False - - model = Gtk.ListStore(str, str) - cell = Gtk.CellRendererText() - self.cb_adapters = Gtk.ComboBox(model=model, visible=True) - self.cb_adapters.set_tooltip_text(_("Adapter selection")) - self.cb_adapters.pack_start(cell, True) - self.cb_adapters.add_attribute(cell, 'text', 0) - - spinner_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6, height_request=8) - self.spinner = Gtk.Spinner(halign=Gtk.Align.START, hexpand=True, has_tooltip=True, - tooltip_text=_("Discovering…"), margin=6) - - spinner_box.add(self.cb_adapters) - spinner_box.add(self.spinner) - self.add(spinner_box) - - self.cb_adapters.connect("changed", self.on_adapter_selected) - - self.List.connect("adapter-changed", self.on_adapter_changed) - self.List.connect("adapter-added", self.on_adapter_added) - self.List.connect("adapter-removed", self.on_adapter_removed) - self.List.connect("adapter-property-changed", self.on_adapter_prop_changed) - - self.update_adapters_list() - self.show_all() - - def __del__(self) -> None: - self.List.destroy() - logging.debug("Deleting widget") - - def on_adapter_prop_changed(self, _devlist: DeviceSelectorList, adapter: Adapter, key_value: tuple[str, object] - ) -> None: - key, value = key_value - if key == "Name" or key == "Alias": - self.update_adapters_list() - elif key == "Discovering": - if not value: - self.spinner.stop() - else: - self.spinner.start() - - def on_adapter_added(self, _devlist: DeviceSelectorList, _adapter_path: str) -> None: - self.update_adapters_list() - - def on_adapter_removed(self, _devlist: DeviceSelectorList, _adapter_path: str) -> None: - self.update_adapters_list() - - def on_adapter_selected(self, cb_adapters: Gtk.ComboBox) -> None: - logging.info("selected") - tree_iter = cb_adapters.get_active_iter() - if tree_iter: - adapter_path = cb_adapters.get_model().get_value(tree_iter, 1) - if self.List.Adapter: - if self.List.Adapter.get_object_path() != adapter_path: - # Stop discovering on previous adapter - self.List.Adapter.stop_discovery() - self.List.set_adapter(os.path.basename(adapter_path)) - # Start discovery on selected adapter - self.List.Adapter.start_discovery() - - def on_adapter_changed(self, _devlist: DeviceSelectorList, adapter_path: str) -> None: - logging.info("changed") - if adapter_path is None: - self.update_adapters_list() - else: - if self.List.Adapter: - self.List.populate_devices() - - def update_adapters_list(self) -> None: - model = self.cb_adapters.get_model() - assert isinstance(model, Gtk.ListStore) - model.clear() - adapters = self.List.manager.get_adapters() - num = len(adapters) - if num == 0: - self.cb_adapters.props.visible = False - self.List.props.sensitive = False - elif num == 1: - self.cb_adapters.props.visible = False - self.List.props.sensitive = True - elif num > 1: - self.List.props.sensitive = True - self.cb_adapters.props.visible = True - for adapter in adapters: - tree_iter = model.append([adapter.get_name(), adapter.get_object_path()]) - if self.List.Adapter and adapter.get_object_path() == self.List.Adapter.get_object_path(): - self.cb_adapters.set_active_iter(tree_iter) diff --git a/blueman/gui/Makefile.am b/blueman/gui/Makefile.am index b15ba3c6f..f197d1441 100644 --- a/blueman/gui/Makefile.am +++ b/blueman/gui/Makefile.am @@ -3,7 +3,7 @@ SUBDIRS = \ manager bluemandir = $(pythondir)/blueman/gui -blueman_PYTHON = Animation.py GsmSettings.py CommonUi.py DeviceList.py DeviceSelectorDialog.py DeviceSelectorList.py DeviceSelectorWidget.py GenericList.py GtkAnimation.py __init__.py Notification.py +blueman_PYTHON = Animation.py GsmSettings.py CommonUi.py DeviceList.py DeviceSelectorDialog.py DeviceSelectorList.py GenericList.py GtkAnimation.py __init__.py Notification.py CLEANFILES = \ $(BUILT_SOURCES) diff --git a/blueman/main/Sendto.py b/blueman/main/Sendto.py index 6d8c6d607..0295cb3c4 100644 --- a/blueman/main/Sendto.py +++ b/blueman/main/Sendto.py @@ -6,21 +6,24 @@ from argparse import Namespace from gettext import ngettext from collections.abc import Iterable, Sequence +from typing import Any -from blueman.bluez.Device import Device +from blueman.bluez.Device import Device, AnyDevice from blueman.bluez.errors import BluezDBusException, DBusNoSuchAdapterError from blueman.main.Builder import Builder from blueman.bluemantyping import GSignals, ObjectPath -from blueman.bluez.Adapter import Adapter +from blueman.bluez.Adapter import Adapter, AnyAdapter from blueman.bluez.Manager import Manager from blueman.bluez.obex.ObjectPush import ObjectPush from blueman.bluez.obex.Manager import Manager as ObexManager from blueman.bluez.obex.Client import Client from blueman.bluez.obex.Transfer import Transfer -from blueman.Functions import format_bytes, log_system_info, bmexit, check_bluetooth_status, setup_icon_path +from blueman.Functions import format_bytes, log_system_info, bmexit, check_bluetooth_status, setup_icon_path, \ + adapter_path_to_name from blueman.main.SpeedCalc import SpeedCalc from blueman.gui.CommonUi import ErrorDialog -from blueman.gui.DeviceSelectorDialog import DeviceSelectorDialog +from blueman.gui.DeviceSelectorDialog import DeviceSelector +from blueman.Sdp import ServiceUUID, OBEX_OBJPUSH_SVCLASS_ID import gi gi.require_version("Gtk", "3.0") @@ -39,9 +42,19 @@ def __init__(self, parsed_args: Namespace) -> None: else: self.files = [os.path.abspath(f) for f in parsed_args.files] - self.device = None - manager = Manager() - adapter = None + self.device: Device | None = None + self._manager = manager = Manager() + self._manager.connect_signal("adapter-added", self.__on_manager_signal, "adapter-added") + self._manager.connect_signal("adapter-removed", self.__on_manager_signal, "adapter-removed") + self._manager.connect_signal("device-created", self.__on_manager_signal, "device-added") + self._manager.connect_signal("device-removed", self.__on_manager_signal, "device-removed") + + self.__any_adapter = AnyAdapter() + self.__any_adapter.connect_signal("property-changed", self.__on_adapter_property_changed) + self.__any_device = AnyDevice() + self.__any_device.connect_signal("property-changed", self.__on_device_property_changed) + + self._adapter = adapter = None adapters = manager.get_adapters() last_adapter_name = Gio.Settings(schema_id="org.blueman.general")["last-adapter"] @@ -62,6 +75,16 @@ def __init__(self, parsed_args: Namespace) -> None: adapter = manager.get_adapter() self.adapter_path = adapter.get_object_path() + adapter_name = adapter_path_to_name(self.adapter_path) + assert adapter_name is not None + + self._device_selector = DeviceSelector(adapter_name=adapter_name) + + for adapter in adapters: + self._device_selector.add_adapter(adapter.get_object_path()) + manager.populate_devices() + + self._device_selector.set_discovering(True) if parsed_args.delete: def delete_files() -> None: @@ -73,7 +96,7 @@ def delete_files() -> None: if not self.select_device(): bmexit() - self.do_send() + self.__schedule_send() else: d = manager.find_device(parsed_args.device, self.adapter_path) @@ -81,9 +104,51 @@ def delete_files() -> None: bmexit("Unknown bluetooth device") self.device = d - self.do_send() + self.__schedule_send() + + def __on_manager_signal(self, _manager: Manager, object_path: ObjectPath, signal_name: str) -> None: + logging.debug(f"{object_path} {signal_name}") + match signal_name: + case "adapter-added": + self._device_selector.add_adapter(object_path) + case "adapter-removed": + self._device_selector.remove_adapter(object_path) + case "device-added": + show_warning = not self._has_objpush(object_path) + self._device_selector.add_device(object_path, show_warning) + case "device-removed": + self._device_selector.remove_device(object_path) + case _: + raise ValueError(f"Unhandled signal {signal_name}") + + def __on_adapter_property_changed(self, _: AnyAdapter, key: str, value: Any, _object_path: ObjectPath) -> None: + if key == "Discovering": + self._device_selector.set_discovering(value) + + def __on_device_property_changed(self, _: AnyDevice, key: str, value: Any, object_path: ObjectPath) -> None: + match key: + case "Alias": + self._device_selector.update_row(object_path, "description", value) + case "UUIDs": + show_warning = not self._has_objpush(object_path) + self._device_selector.update_row(object_path, "warning", show_warning) + + def _has_objpush(self, object_path: ObjectPath) -> bool: + device = Device(obj_path=object_path) + for uuid in device["UUIDs"]: + if ServiceUUID(uuid).short_uuid == OBEX_OBJPUSH_SVCLASS_ID: + return True + return False + + def _start_discover(self) -> None: + for adapter in self._manager.get_adapters(): + adapter.start_discovery() + + def __schedule_send(self) -> None: + # Some adapter don't handle pushing files right after discovering. + GLib.timeout_add_seconds(2, self.do_send) - def do_send(self) -> None: + def do_send(self) -> bool: if not self.files: logging.warning("No files to send") bmexit() @@ -96,6 +161,8 @@ def on_result(_sender: Sender, _res: bool) -> None: sender.connect("result", on_result) + return False + @staticmethod def select_files() -> Sequence[str]: d = Gtk.FileChooserDialog(title=_("Select files to send"), icon_name='blueman-send-symbolic') @@ -112,13 +179,12 @@ def select_files() -> Sequence[str]: quit() def select_device(self) -> bool: - adapter_name = os.path.split(self.adapter_path)[-1] - d = DeviceSelectorDialog(discover=True, adapter_name=adapter_name) - resp = d.run() - d.close() + self._start_discover() + resp = self._device_selector.run() + self._device_selector.close() if resp == Gtk.ResponseType.ACCEPT: - if d.selection: - self.adapter_path, self.device = d.selection + if self._device_selector.selection: + self.adapter_path, self.device = self._device_selector.selection return True else: return False diff --git a/data/ui/Makefile.am b/data/ui/Makefile.am index 24743aab7..c894c2bad 100644 --- a/data/ui/Makefile.am +++ b/data/ui/Makefile.am @@ -7,6 +7,8 @@ ui_DATA = \ services-network.ui \ services-transfer.ui \ send-dialog.ui \ + sendto-device-dialog.ui \ + sendto-rowbox.ui \ applet-plugins-widget.ui \ gsm-settings.ui \ net-usage.ui \ diff --git a/data/ui/sendto-device-dialog.ui b/data/ui/sendto-device-dialog.ui new file mode 100644 index 000000000..1b1d8f9d9 --- /dev/null +++ b/data/ui/sendto-device-dialog.ui @@ -0,0 +1,167 @@ + + + + + + + + False + 350 + 200 + blueman + dialog + + + False + vertical + 2 + + + False + end + + + True + _Cancel + True + True + True + + + True + True + + + + + True + _OK + True + True + True + + + True + True + + + + + False + False + + + + + + False + True + True + True + + + False + 2 + 2 + 5 + True + + + False + <b>Adapter:</b> + 5 + 5 + True + True + + + False + True + + + + + any + False + True + + any + + + + False + True + + + + + True + + + True + True + Discovering + True + True + + + + + True + False + True + + + + + + + 0 + 1 + + + + + True + True + never + 2 + 2 + False + in + True + True + + + False + True + + + False + True + True + True + + + + + + + 0 + 0 + + + + + False + True + + + + + + button_cancel + button_ok + + + diff --git a/data/ui/sendto-rowbox.ui b/data/ui/sendto-rowbox.ui new file mode 100644 index 000000000..f7b9d8ad4 --- /dev/null +++ b/data/ui/sendto-rowbox.ui @@ -0,0 +1,56 @@ + + + + + + + + False + 5 + True + + + False + blueman + True + + + False + True + + + + + False + start + bluetooth device alias + True + + + True + + + + + start + (hcix) + True + + + True + + + + + False + dialog-warning-symbolic + 2 + 2 + 2 + 2 + Device does not advertise it can receive files, transfer may fail. + True + + + + diff --git a/po/POTFILES.in b/po/POTFILES.in index 71eb09907..bd5f8619a 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -45,7 +45,6 @@ blueman/gui/GenericList.py blueman/gui/applet/__init__.py blueman/gui/applet/PluginDialog.py blueman/gui/Notification.py -blueman/gui/DeviceSelectorWidget.py blueman/DeviceClass.py blueman/__init__.py blueman/Sdp.py