diff --git a/blueman/gui/DeviceSelectorDialog.py b/blueman/gui/DeviceSelectorDialog.py
index 28babb445..3f9299e7f 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..5c223f098 100644
--- a/blueman/main/Sendto.py
+++ b/blueman/main/Sendto.py
@@ -6,21 +6,23 @@
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 +41,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 +74,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 +95,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 +103,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 +160,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 +178,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 @@
+
+
+
+
+
+
+
+
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
+
+
+
+