diff --git a/blueman/Functions.py b/blueman/Functions.py
index 2e48e8aab..7a13a4e28 100644
--- a/blueman/Functions.py
+++ b/blueman/Functions.py
@@ -23,6 +23,7 @@
import re
import os
import pathlib
+import shutil
import sys
import errno
from gettext import gettext as _
@@ -49,6 +50,7 @@
from gi.repository import Gdk
from gi.repository import GdkPixbuf
from gi.repository import Gio
+from gi.repository import GLib
__all__ = ["check_bluetooth_status", "launch", "setup_icon_path", "adapter_path_to_name", "e_", "bmexit",
"format_bytes", "create_menuitem", "have", "set_proc_title", "create_logger", "create_parser", "open_rfcomm",
@@ -59,9 +61,8 @@ def check_bluetooth_status(message: str, exitfunc: Callable[[], Any]) -> None:
try:
applet = AppletService()
powermanager = AppletPowerManagerService()
- except DBusProxyFailed as e:
- logging.exception(e)
- print("Blueman applet needs to be running")
+ except DBusProxyFailed:
+ logging.exception("Blueman applet needs to be running")
exitfunc()
return
@@ -84,7 +85,7 @@ def check_bluetooth_status(message: str, exitfunc: Callable[[], Any]) -> None:
powermanager.set_bluetooth_status(True)
if not powermanager.get_bluetooth_status():
- print('Failed to enable bluetooth')
+ logging.error("Failed to enable bluetooth")
exitfunc()
@@ -95,8 +96,17 @@ def launch(
icon_name: str | None = None,
name: str = "blueman",
sn: bool = True,
+ args: Iterable[str] | None = None,
) -> bool:
- """Launch a gui app with startup notification"""
+ """Launch a gui app with startup notification.
+
+ ``cmd`` is the program to run. Pass options as an ``args`` iterable rather
+ than embedding them in ``cmd``: each program token and argument is then
+ shell-quoted individually so argument boundaries follow an argv contract
+ instead of GLib command-line parsing. The legacy form, where ``cmd`` itself
+ is a full command line, is still accepted when ``args`` is omitted but is
+ deprecated.
+ """
context = None
gtktimestamp = Gtk.get_current_event_time()
if gtktimestamp == 0:
@@ -122,6 +132,13 @@ def launch(
else:
command = pathlib.Path(cmd).expanduser()
+ if args is not None:
+ # argv contract: quote each token so spaces/quotes/separators in
+ # arguments cannot cross argument boundaries.
+ command_line = " ".join(GLib.shell_quote(arg) for arg in (command.as_posix(), *args))
+ else:
+ command_line = command.as_posix()
+
if paths:
files: list[Gio.File] | None = [Gio.File.new_for_commandline_arg(p) for p in paths]
else:
@@ -130,7 +147,7 @@ def launch(
if icon_name and context is not None:
context.set_icon_name(icon_name)
- appinfo = Gio.AppInfo.create_from_commandline(command.as_posix(), name, flags)
+ appinfo = Gio.AppInfo.create_from_commandline(command_line, name, flags)
launched: bool = appinfo.launch(files, context)
if not launched:
@@ -168,10 +185,10 @@ def format_bytes(size: float) -> tuple[float, str]:
if size < 1024:
ret = size
suffix = _("B")
- elif 1024 < size < (1024 * 1024):
+ elif size < (1024 * 1024):
ret = size / 1024
suffix = _("KB")
- elif (1024 * 1024) < size < (1024 * 1024 * 1024):
+ elif size < (1024 * 1024 * 1024):
ret = size / (1024 * 1024)
suffix = _("MB")
else:
@@ -207,23 +224,39 @@ def create_menuitem(
def have(t: str) -> pathlib.Path | None:
- pathstr = os.environ['PATH'] + ':/sbin:/usr/sbin'
- for path in [pathlib.Path(p, t) for p in pathstr.split(":")]:
- if path.exists() and os.access(path, os.EX_OK):
- return path
- return None
+ search_path = os.environ.get("PATH", os.defpath)
+ # System binaries such as dhcp clients commonly live in sbin dirs that are
+ # not on a desktop session's PATH; append them if missing.
+ for sbin in ("/sbin", "/usr/sbin"):
+ if sbin not in search_path.split(os.pathsep):
+ search_path += os.pathsep + sbin
+
+ found = shutil.which(t, path=search_path)
+ return pathlib.Path(found) if found else None
def set_proc_title(name: str | None = None) -> int:
- """Set the process title"""
+ """Set the process title via ``prctl(PR_SET_NAME)``.
+
+ Only Linux exposes ``prctl`` through glibc; on other platforms this is a
+ no-op returning 0. Returns -1 if libc/prctl cannot be reached.
+ """
if not name:
name = pathlib.Path(sys.argv[0]).name
- libc = cdll.LoadLibrary('libc.so.6')
- buff = create_string_buffer(len(name) + 1)
- buff.value = name.encode("UTF-8")
- ret: int = libc.prctl(15, byref(buff), 0, 0, 0)
+ if not sys.platform.startswith("linux"):
+ logging.debug("set_proc_title is only supported on Linux")
+ return 0
+
+ try:
+ libc = cdll.LoadLibrary('libc.so.6')
+ buff = create_string_buffer(len(name) + 1)
+ buff.value = name.encode("UTF-8")
+ ret: int = libc.prctl(15, byref(buff), 0, 0, 0)
+ except (OSError, AttributeError):
+ logging.exception("Failed to set process title")
+ return -1
if ret != 0:
logging.error("Failed to set process title")
@@ -243,6 +276,12 @@ def create_logger(
date_fmt: str | None = None,
syslog: bool = False,
) -> logging.Logger:
+ """Configure and return the root logger for an entry point.
+
+ Used by every blueman binary (see ``apps/*.in``) to set the process-wide
+ log level, name, and format. With ``syslog`` enabled a SysLogHandler is
+ added when ``/dev/log`` is available, otherwise logging stays on stderr.
+ """
if log_format is None:
log_format = logger_format
if date_fmt is None:
@@ -253,10 +292,16 @@ def create_logger(
logger.name = name
if syslog:
- syslog_handler = logging.handlers.SysLogHandler(address="/dev/log")
- syslog_formatter = logging.Formatter(syslog_logger_format)
- syslog_handler.setFormatter(syslog_formatter)
- logger.addHandler(syslog_handler)
+ try:
+ syslog_handler = logging.handlers.SysLogHandler(address="/dev/log")
+ except OSError:
+ # /dev/log is absent on non-Linux platforms and minimal containers;
+ # the basicConfig stderr handler still provides logging.
+ logging.warning("Syslog socket /dev/log unavailable, logging to stderr only")
+ else:
+ syslog_formatter = logging.Formatter(syslog_logger_format)
+ syslog_handler.setFormatter(syslog_formatter)
+ logger.addHandler(syslog_handler)
return logger
@@ -266,11 +311,19 @@ def create_parser(
syslog: bool = True,
loglevel: bool = True,
) -> argparse.ArgumentParser:
+ """Build the shared argument parser used by every blueman entry point.
+
+ Adds the common ``--loglevel`` and ``--syslog`` options (each toggleable)
+ so all binaries in ``apps/*.in`` expose a consistent CLI surface.
+ """
if parser is None:
parser = argparse.ArgumentParser()
if loglevel:
- parser.add_argument("--loglevel", dest="LEVEL", default="warning")
+ parser.add_argument(
+ "--loglevel", dest="LEVEL", default="warning", type=str.lower,
+ choices=["debug", "info", "warning", "error", "critical"],
+ help="Logging verbosity (case-insensitive); defaults to warning.")
if syslog:
parser.add_argument("--syslog", dest="syslog", action="store_true")
@@ -327,7 +380,7 @@ def get_local_interfaces() -> dict[str, tuple[str, str | None]]:
mask = _netmask_for_ifacename(name, sock)
ip_dict[name] = (ipaddr, mask)
except OSError:
- logging.error('Socket creation failed', exc_info=True)
+ logging.exception('Socket creation failed')
return {}
return ip_dict
@@ -337,24 +390,25 @@ def bmexit(msg: str | int | None = None) -> None:
raise SystemExit(msg)
-def log_system_info() -> None:
- def parse_os_release(path: Path) -> dict[str, str]:
- release_dict = {}
- try:
- with path.open() as f:
- for line in f:
- line = line.strip()
- if line.startswith("#"):
- continue
- try:
- key, val = line.split("=")
- release_dict[key] = val.strip("\"")
- except ValueError:
- logging.error(f"Unable to parse line: {line}")
- except OSError:
- logging.error(f"Could not read {path.as_uri()}")
- return release_dict
+def parse_os_release(path: Path) -> dict[str, str]:
+ release_dict = {}
+ try:
+ with path.open() as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ key, sep, val = line.partition("=")
+ if not sep:
+ logging.error(f"Unable to parse line: {line}")
+ continue
+ release_dict[key] = val.strip("\"")
+ except OSError:
+ logging.error(f"Could not read {path.as_uri()}")
+ return release_dict
+
+def log_system_info() -> None:
try:
complete = subprocess.run(
[BLUETOOTHD_PATH, "-v"],
diff --git a/blueman/plugins/applet/TransferService.py b/blueman/plugins/applet/TransferService.py
index 29a47769b..bf797eb36 100644
--- a/blueman/plugins/applet/TransferService.py
+++ b/blueman/plugins/applet/TransferService.py
@@ -1,11 +1,12 @@
from datetime import datetime
from gettext import gettext as _, ngettext
from pathlib import Path
+import os
import shutil
import logging
from html import escape
-from typing import Optional, TypedDict, Union
-from collections.abc import Callable
+from typing import TypedDict, Union
+from collections.abc import Callable, Iterator
from blueman.bluemantyping import ObjectPath, BtAddress
from blueman.bluez.obex.AgentManager import AgentManager
@@ -14,7 +15,6 @@
from blueman.bluez.obex.Session import Session
from blueman.Functions import launch
from blueman.gui.Notification import Notification, _NotificationBubble, _NotificationDialog
-from blueman.main.Applet import BluemanApplet
from blueman.main.DbusService import DbusService, DbusError
from blueman.plugins.AppletPlugin import AppletPlugin
@@ -38,6 +38,37 @@ class PendingTransferDict(TypedDict):
NotificationType = Union[_NotificationBubble, _NotificationDialog]
+# Resolve (display name, trusted) for a device by adapter source path + address.
+# May raise; callers treat a raised resolver as an untrusted/unknown device.
+DeviceResolver = Callable[[str, BtAddress], tuple[str, bool]]
+
+_MAX_DESTINATION_ATTEMPTS = 10000
+
+
+def _destination_candidates(filename: str, stamp: str) -> "Iterator[str]":
+ yield filename
+ yield f"{stamp}_{filename}"
+ for index in range(1, _MAX_DESTINATION_ATTEMPTS):
+ yield f"{stamp}_{index}_{filename}"
+
+
+def reserve_destination(dest_dir: Path, filename: str, now: datetime) -> Path:
+ """Atomically reserve a unique destination, returning the reserved (empty) path.
+
+ Uses O_EXCL so two transfers completing in the same second cannot pick the
+ same name and overwrite each other; the caller moves the source onto it.
+ """
+ stamp = now.strftime('%Y%m%d%H%M%S')
+ for candidate in _destination_candidates(filename, stamp):
+ dest = dest_dir / candidate
+ try:
+ fd = os.open(dest, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
+ except FileExistsError:
+ continue
+ os.close(fd)
+ return dest
+ raise FileExistsError(f"No free destination for {filename} in {dest_dir}")
+
class ObexErrorRejected(DbusError):
_name = "org.bluez.obex.Error.Rejected"
@@ -50,7 +81,7 @@ class ObexErrorCanceled(DbusError):
class Agent(DbusService):
__agent_path = ObjectPath('/org/bluez/obex/agent/blueman')
- def __init__(self, applet: BluemanApplet):
+ def __init__(self, resolve_device: "DeviceResolver"):
super().__init__(None, "org.bluez.obex.Agent1", self.__agent_path, Gio.BusType.SESSION)
self.add_method("Release", (), "", self._release)
@@ -58,12 +89,12 @@ def __init__(self, applet: BluemanApplet):
self.add_method("AuthorizePush", ("o",), "s", self._authorize_push, is_async=True)
self.register()
- self._applet = applet
+ self._resolve_device = resolve_device
self._config = Gio.Settings(schema_id="org.blueman.transfer")
- self._allowed_devices: list[str] = []
+ self._allowed_devices: set[BtAddress] = set()
self._notification: NotificationType | None = None
- self._pending_transfer: Optional[PendingTransferDict] = None
+ self._pending_transfers: dict[ObjectPath, PendingTransferDict] = {}
self.transfers: dict[ObjectPath, TransferDict] = {}
def register_at_manager(self) -> None:
@@ -77,30 +108,6 @@ def _release(self) -> None:
def _authorize_push(self, transfer_path: ObjectPath, ok: Callable[[str], None],
err: Callable[[ObexErrorRejected], None]) -> None:
- def on_action(action: str) -> None:
- logging.info(f"Action {action}")
-
- if action == "accept":
- assert self._pending_transfer
- self.transfers[self._pending_transfer['transfer_path']] = {
- 'path': self._pending_transfer['root'] / self._pending_transfer['filename'],
- 'size': self._pending_transfer['size'],
- 'name': self._pending_transfer['name']
- }
-
- ok(self.transfers[self._pending_transfer['transfer_path']]['path'].as_posix())
-
- self._allowed_devices.append(self._pending_transfer['address'])
-
- def _remove() -> bool:
- assert self._pending_transfer is not None # https://github.com/python/mypy/issues/2608
- self._allowed_devices.remove(self._pending_transfer['address'])
- return False
-
- GLib.timeout_add(60000, _remove)
- else:
- err(ObexErrorRejected("Rejected"))
-
transfer = Transfer(obj_path=transfer_path)
session = Session(obj_path=transfer.session)
root = Path(session.root)
@@ -109,18 +116,39 @@ def _remove() -> bool:
size = transfer.size
try:
- adapter = self._applet.Manager.get_adapter(session.source)
- device = self._applet.Manager.find_device(address, adapter.get_object_path())
- assert device is not None
- name = device.display_name
- trusted = device["Trusted"]
+ name, trusted = self._resolve_device(session.source, address)
except Exception as e:
logging.exception(e)
name = address
trusted = False
- self._pending_transfer = {'transfer_path': transfer_path, 'address': address, 'root': root,
- 'filename': filename, 'size': size, 'name': name}
+ pending: PendingTransferDict = {'transfer_path': transfer_path, 'address': address, 'root': root,
+ 'filename': filename, 'size': size, 'name': name}
+ self._pending_transfers[transfer_path] = pending
+
+ def on_action(action: str, pending: PendingTransferDict = pending) -> None:
+ logging.info(f"Action {action}")
+ self._pending_transfers.pop(pending['transfer_path'], None)
+
+ if action == "accept":
+ self.transfers[pending['transfer_path']] = {
+ 'path': pending['root'] / pending['filename'],
+ 'size': pending['size'],
+ 'name': pending['name']
+ }
+
+ ok(self.transfers[pending['transfer_path']]['path'].as_posix())
+
+ allowed_address = pending['address']
+ self._allowed_devices.add(allowed_address)
+
+ def _remove(address: BtAddress = allowed_address) -> bool:
+ self._allowed_devices.discard(address)
+ return False
+
+ GLib.timeout_add(60000, _remove)
+ else:
+ err(ObexErrorRejected("Rejected"))
# This device was neither allowed nor is it trusted -> ask for confirmation
if address not in self._allowed_devices and not (self._config['opp-accept'] and trusted):
@@ -174,6 +202,7 @@ def on_reset(_action: str) -> None:
logging.info('Reset share path')
self._config = Gio.Settings(schema_id="org.blueman.transfer")
+ self._handlerids = [] # per-instance; avoid sharing the class-level list across instances
share_path, invalid_share_path = self._make_share_path()
@@ -181,9 +210,10 @@ def on_reset(_action: str) -> None:
text = _('Configured directory for incoming files does not exist')
secondary_text = _('Please make sure that directory "%s" exists or '
'configure it with blueman-services. Until then the default "%s" will be used')
- self._notification = Notification(text, secondary_text % (self._config["shared-path"], share_path),
+ self._notification = Notification(text, secondary_text % (escape(self._config["shared-path"]),
+ escape(share_path.as_posix())),
icon_name='blueman', timeout=30000,
- actions=[('reset', 'Reset to default')], actions_cb=on_reset)
+ actions=[('reset', _('Reset to default'))], actions_cb=on_reset)
self._notification.show()
self._watch = Manager.watch_name_owner(self._on_dbus_name_appeared, self._on_dbus_name_vanished)
@@ -196,7 +226,8 @@ def on_unload(self) -> None:
def _make_share_path(self) -> tuple[Path, bool]:
config_path = Path(self._config["shared-path"])
- default_path = Path(GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DOWNLOAD))
+ download_dir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DOWNLOAD)
+ default_path = Path(download_dir) if download_dir else None
path = None
error = False
@@ -220,9 +251,15 @@ def _make_share_path(self) -> tuple[Path, bool]:
return path, error
+ def _resolve_device(self, source: str, address: BtAddress) -> tuple[str, bool]:
+ adapter = self.parent.Manager.get_adapter(source)
+ device = self.parent.Manager.find_device(address, adapter.get_object_path())
+ assert device is not None
+ return device.display_name, device["Trusted"]
+
def _register_agent(self) -> None:
if not self._agent:
- self._agent = Agent(self.parent)
+ self._agent = Agent(self._resolve_device)
self._agent.register_at_manager()
def _unregister_agent(self) -> None:
@@ -291,18 +328,17 @@ def _on_transfer_completed(self, _manager: Manager, transfer_path: ObjectPath, s
src = attributes['path']
dest_dir, ignored = self._make_share_path()
- filename = src.name
- if dest_dir.joinpath(filename).exists():
- now = datetime.now()
- filename = f"{now.strftime('%Y%m%d%H%M%S')}_{filename}"
+ dest = reserve_destination(dest_dir, src.name, datetime.now())
+ filename = dest.name
+ if filename != src.name:
logging.info(f"Destination file exists, renaming to: {filename}")
- dest = dest_dir.joinpath(filename)
try:
shutil.move(src, dest)
except (OSError, PermissionError):
logging.error("Failed to move files", exc_info=True)
+ dest.unlink(missing_ok=True)
success = False
if success:
@@ -313,7 +349,7 @@ def _on_transfer_completed(self, _manager: Manager, transfer_path: ObjectPath, s
icon_name="blueman")
self._add_open(self._notification, _("Open"), dest)
self._notification.show()
- elif not success:
+ else:
n = Notification(
_("Transfer failed"),
_("Transfer of file %(0)s failed") % {
@@ -352,3 +388,7 @@ def _on_session_removed(self, _manager: Manager, _session_path: str) -> None:
icon_name="blueman")
self._add_open(self._notification, _("Open Location"), share_path)
self._notification.show()
+
+ # The summary consumes the counts; reset so the next session starts fresh.
+ self._silent_transfers = 0
+ self._normal_transfers = 0
diff --git a/blueman/plugins/manager/Notes.py b/blueman/plugins/manager/Notes.py
index e37149c69..3deb3fc9d 100644
--- a/blueman/plugins/manager/Notes.py
+++ b/blueman/plugins/manager/Notes.py
@@ -32,7 +32,7 @@ def send_note_cb(dialog: Gtk.Dialog, response_id: int, device_address: str, text
tempfile = NamedTemporaryFile(suffix='.vnt', prefix='note', delete=False)
tempfile.write(data.encode('utf-8'))
tempfile.close()
- launch(f"blueman-sendto --delete --device={device_address}", paths=[tempfile.name])
+ launch("blueman-sendto", args=["--delete", f"--device={device_address}"], paths=[tempfile.name])
def send_note(device: Device, parent: Gtk.ApplicationWindow) -> None:
diff --git a/test/Makefile.am b/test/Makefile.am
index 0333b925e..7bea7ffc0 100644
--- a/test/Makefile.am
+++ b/test/Makefile.am
@@ -11,4 +11,5 @@ SUBDIRS = \
EXTRA_DIST = \
__init__.py \
test_imports.py \
- test_gobject.py
+ test_gobject.py \
+ test_functions.py
diff --git a/test/main/test_dns_server_provider.py b/test/main/test_dns_server_provider.py
index 1de3dd0c3..2224988a4 100644
--- a/test/main/test_dns_server_provider.py
+++ b/test/main/test_dns_server_provider.py
@@ -78,6 +78,14 @@ def _test_changed(action: Callable[[], None]) -> None:
action()
context = GLib.MainContext.default()
- while context.pending():
- context.iteration()
+ timed_out = False
+
+ def on_timeout() -> bool:
+ nonlocal timed_out
+ timed_out = True
+ return GLib.SOURCE_REMOVE
+
+ GLib.timeout_add_seconds(5, on_timeout)
+ while not mock.called and not timed_out:
+ context.iteration(may_block=True)
mock.assert_called_with(provider)
diff --git a/test/plugins/applet/Makefile.am b/test/plugins/applet/Makefile.am
index 3e92b4be6..0530f72df 100644
--- a/test/plugins/applet/Makefile.am
+++ b/test/plugins/applet/Makefile.am
@@ -1,3 +1,4 @@
EXTRA_DIST = \
__init__.py \
- test_imports.py
+ test_imports.py \
+ test_transfer_service.py
diff --git a/test/plugins/applet/test_transfer_service.py b/test/plugins/applet/test_transfer_service.py
new file mode 100644
index 000000000..1a8254258
--- /dev/null
+++ b/test/plugins/applet/test_transfer_service.py
@@ -0,0 +1,683 @@
+import tempfile
+from datetime import datetime
+from pathlib import Path
+from unittest import TestCase
+from unittest.mock import MagicMock, patch
+
+from blueman.plugins.applet.TransferService import Agent, TransferService, reserve_destination
+
+
+def _make_agent(resolve_device: object = None) -> Agent:
+ agent = Agent.__new__(Agent)
+ agent._allowed_devices = set()
+ agent._notification = None
+ agent._pending_transfers = {}
+ agent.transfers = {}
+ config = MagicMock()
+ config.__getitem__.side_effect = lambda key: True if key == "opp-accept" else ""
+ agent._config = config
+ agent._resolve_device = resolve_device or (lambda source, address: ("Phone", True))
+ return agent
+
+
+def _configure_transfer(transfer_mock: MagicMock, session_mock: MagicMock, *, address: str,
+ name: str = "file.bin", size: int = 10) -> None:
+ transfer = transfer_mock.return_value
+ transfer.session = "/sess"
+ transfer.name = name
+ transfer.size = size
+ session = session_mock.return_value
+ session.root = "/root"
+ session.address = address
+ session.source = "/org/bluez/hci0"
+
+
+def _make_plugin(configured_share_path: str) -> TransferService:
+ plugin = TransferService.__new__(TransferService)
+ config = MagicMock()
+ config.__getitem__.side_effect = lambda key: {"shared-path": configured_share_path}[key]
+ plugin._config = config
+ return plugin
+
+
+@patch("blueman.plugins.applet.TransferService.Manager")
+@patch("blueman.plugins.applet.TransferService.Notification")
+@patch("blueman.plugins.applet.TransferService.Gio.Settings")
+class TestSharePathEscaping(TestCase):
+ def _body(self, settings_mock: MagicMock, notification_mock: MagicMock, configured: str) -> str:
+ plugin = _make_plugin(configured)
+ settings_mock.return_value = plugin._config
+ with patch.object(TransferService, "_make_share_path", return_value=(Path("/srv/Downloads"), True)):
+ plugin.on_load()
+ notification_mock.assert_called_once()
+ return notification_mock.call_args.args[1]
+
+ def test_escapes_angle_brackets(self, settings_mock: MagicMock, notification_mock: MagicMock,
+ _manager_mock: MagicMock) -> None:
+ body = self._body(settings_mock, notification_mock, "/home/evil")
+ self.assertNotIn("evil", body)
+ self.assertIn("<b>evil</b>", body)
+
+ def test_escapes_ampersand(self, settings_mock: MagicMock, notification_mock: MagicMock,
+ _manager_mock: MagicMock) -> None:
+ body = self._body(settings_mock, notification_mock, "/home/a & b")
+ self.assertIn("&", body)
+
+ def test_escapes_quotes(self, settings_mock: MagicMock, notification_mock: MagicMock,
+ _manager_mock: MagicMock) -> None:
+ body = self._body(settings_mock, notification_mock, '/home/"quoted"')
+ self.assertNotIn('"quoted"', body)
+ self.assertIn(""quoted"", body)
+
+
+@patch("blueman.plugins.applet.TransferService.Manager")
+@patch("blueman.plugins.applet.TransferService.Notification")
+@patch("blueman.plugins.applet.TransferService.Gio.Settings")
+class TestResetActionTranslated(TestCase):
+ def test_reset_label_routed_through_gettext(self, settings_mock: MagicMock, notification_mock: MagicMock,
+ _manager_mock: MagicMock) -> None:
+ plugin = _make_plugin("/does/not/matter")
+ settings_mock.return_value = plugin._config
+ with patch.object(TransferService, "_make_share_path", return_value=(Path("/srv/Downloads"), True)), \
+ patch("blueman.plugins.applet.TransferService._", lambda s: f"
{s}"):
+ plugin.on_load()
+ actions = notification_mock.call_args.kwargs["actions"]
+ self.assertEqual(actions, [("reset", "
Reset to default")])
+
+
+@patch("blueman.plugins.applet.TransferService.GLib")
+@patch("blueman.plugins.applet.TransferService.Notification")
+@patch("blueman.plugins.applet.TransferService.Session")
+@patch("blueman.plugins.applet.TransferService.Transfer")
+class TestAllowedDeviceRemoval(TestCase):
+ def _authorize(self, transfer_mock: MagicMock, session_mock: MagicMock, glib_mock: MagicMock,
+ address: str) -> tuple[Agent, object]:
+ agent = _make_agent()
+ _configure_transfer(transfer_mock, session_mock, address=address)
+ agent._authorize_push("/transfer", MagicMock(), MagicMock())
+ remove_cb = glib_mock.timeout_add.call_args.args[1]
+ return agent, remove_cb
+
+ def test_address_allowed_then_removed(self, transfer_mock: MagicMock, session_mock: MagicMock,
+ _notification_mock: MagicMock, glib_mock: MagicMock) -> None:
+ agent, remove_cb = self._authorize(transfer_mock, session_mock, glib_mock, "AA:BB:CC:DD:EE:FF")
+ self.assertIn("AA:BB:CC:DD:EE:FF", agent._allowed_devices)
+ remove_cb()
+ self.assertNotIn("AA:BB:CC:DD:EE:FF", agent._allowed_devices)
+
+ def test_removal_is_idempotent(self, transfer_mock: MagicMock, session_mock: MagicMock,
+ _notification_mock: MagicMock, glib_mock: MagicMock) -> None:
+ agent, remove_cb = self._authorize(transfer_mock, session_mock, glib_mock, "AA:BB:CC:DD:EE:FF")
+ remove_cb()
+ remove_cb() # must not raise even though the address is already gone
+
+ def test_removes_captured_address_not_current_pending(self, transfer_mock: MagicMock, session_mock: MagicMock,
+ _notification_mock: MagicMock, glib_mock: MagicMock) -> None:
+ agent, remove_cb = self._authorize(transfer_mock, session_mock, glib_mock, "AA:BB:CC:DD:EE:FF")
+ agent._allowed_devices.add("11:22:33:44:55:66") # a later, overlapping request's address
+ remove_cb()
+ self.assertNotIn("AA:BB:CC:DD:EE:FF", agent._allowed_devices)
+ self.assertIn("11:22:33:44:55:66", agent._allowed_devices)
+
+
+@patch("blueman.plugins.applet.TransferService.GLib")
+@patch("blueman.plugins.applet.TransferService.Notification")
+@patch("blueman.plugins.applet.TransferService.Session")
+@patch("blueman.plugins.applet.TransferService.Transfer")
+class TestOverlappingPending(TestCase):
+ def _setup_two_untrusted(self, agent: Agent, transfer_mock: MagicMock, session_mock: MagicMock) -> None:
+ agent._resolve_device = lambda source, address: ("Phone", False)
+
+ def make_transfer(obj_path: str) -> MagicMock:
+ transfer = MagicMock()
+ transfer.session = "/s" + obj_path
+ transfer.name = "first.bin" if obj_path == "/t1" else "second.bin"
+ transfer.size = 10
+ return transfer
+
+ def make_session(obj_path: str) -> MagicMock:
+ session = MagicMock()
+ session.root = "/root"
+ session.address = "AA:AA" if obj_path == "/s/t1" else "BB:BB"
+ session.source = "/org/bluez/hci0"
+ return session
+
+ transfer_mock.side_effect = make_transfer
+ session_mock.side_effect = make_session
+
+ def test_overlapping_requests_keep_independent_records(self, transfer_mock: MagicMock, session_mock: MagicMock,
+ notification_mock: MagicMock, _glib_mock: MagicMock) -> None:
+ agent = _make_agent()
+ self._setup_two_untrusted(agent, transfer_mock, session_mock)
+ ok_a, err_a, ok_b, err_b = MagicMock(), MagicMock(), MagicMock(), MagicMock()
+
+ agent._authorize_push("/t1", ok_a, err_a)
+ agent._authorize_push("/t2", ok_b, err_b)
+ self.assertEqual(set(agent._pending_transfers), {"/t1", "/t2"})
+
+ on_action_a = notification_mock.call_args_list[0].kwargs["actions_cb"]
+ on_action_b = notification_mock.call_args_list[1].kwargs["actions_cb"]
+
+ on_action_a("accept")
+ self.assertEqual(agent.transfers["/t1"]["path"].name, "first.bin")
+ ok_a.assert_called_once()
+ self.assertNotIn("/t1", agent._pending_transfers)
+ self.assertIn("/t2", agent._pending_transfers) # the second request is untouched by the first's action
+
+ on_action_b("reject")
+ err_b.assert_called_once()
+ self.assertNotIn("/t2", agent._pending_transfers)
+ self.assertNotIn("/t2", agent.transfers)
+
+
+class TestReserveDestination(TestCase):
+ _NOW = datetime(2020, 1, 2, 3, 4, 5)
+ _STAMP = "20200102030405"
+
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self._tmp.name)
+ self.addCleanup(self._tmp.cleanup)
+
+ def test_no_collision_uses_plain_name(self) -> None:
+ dest = reserve_destination(self.dir, "file.bin", self._NOW)
+ self.assertEqual(dest, self.dir / "file.bin")
+ self.assertTrue(dest.exists())
+
+ def test_first_collision_uses_timestamp(self) -> None:
+ (self.dir / "file.bin").write_text("existing")
+ dest = reserve_destination(self.dir, "file.bin", self._NOW)
+ self.assertEqual(dest, self.dir / f"{self._STAMP}_file.bin")
+
+ def test_second_collision_uses_indexed_timestamp(self) -> None:
+ (self.dir / "file.bin").write_text("existing")
+ (self.dir / f"{self._STAMP}_file.bin").write_text("existing")
+ dest = reserve_destination(self.dir, "file.bin", self._NOW)
+ self.assertEqual(dest, self.dir / f"{self._STAMP}_1_file.bin")
+
+ def test_same_second_calls_never_collide(self) -> None:
+ # Two transfers of the same name completing in the same second must each
+ # reserve a distinct, freshly-created destination — no overwrite.
+ reserved = [reserve_destination(self.dir, "photo.jpg", self._NOW) for _ in range(5)]
+ self.assertEqual(len(set(reserved)), 5)
+ for dest in reserved:
+ self.assertTrue(dest.exists())
+
+ def test_reserved_file_is_exclusive(self) -> None:
+ dest = reserve_destination(self.dir, "x", self._NOW)
+ # A second reservation must not hand back the same path it just created.
+ other = reserve_destination(self.dir, "x", self._NOW)
+ self.assertNotEqual(dest, other)
+
+ def test_fuzz_weird_names_stay_unique_and_safe(self) -> None:
+ names = ["a b.bin", "résumé.pdf", ".hidden", "name.with.dots.tar.gz",
+ "UPPER.TXT", " spaces ", "emoji-😀.png", "a" * 200 + ".bin"]
+ for raw in names:
+ with self.subTest(name=raw):
+ first = reserve_destination(self.dir, raw, self._NOW)
+ second = reserve_destination(self.dir, raw, self._NOW)
+ self.assertNotEqual(first, second)
+ self.assertTrue(first.exists() and second.exists())
+ # Reserved name must stay within the destination directory.
+ self.assertEqual(first.parent, self.dir)
+ self.assertEqual(second.parent, self.dir)
+
+
+class TestDeviceResolverInjection(TestCase):
+ def test_plugin_resolver_delegates_to_manager(self) -> None:
+ plugin = TransferService.__new__(TransferService)
+ plugin.parent = MagicMock()
+ device = plugin.parent.Manager.find_device.return_value
+ device.display_name = "Watch"
+ device.__getitem__.side_effect = lambda key: True if key == "Trusted" else None
+
+ name, trusted = plugin._resolve_device("/org/bluez/hci0", "AA:BB:CC:DD:EE:FF")
+
+ self.assertEqual((name, trusted), ("Watch", True))
+ plugin.parent.Manager.get_adapter.assert_called_once_with("/org/bluez/hci0")
+ plugin.parent.Manager.find_device.assert_called_once()
+
+ @patch("blueman.plugins.applet.TransferService.GLib")
+ @patch("blueman.plugins.applet.TransferService.Notification")
+ @patch("blueman.plugins.applet.TransferService.Session")
+ @patch("blueman.plugins.applet.TransferService.Transfer")
+ def test_agent_uses_injected_resolver(self, transfer_mock: MagicMock, session_mock: MagicMock,
+ _notification_mock: MagicMock, _glib_mock: MagicMock) -> None:
+ resolver = MagicMock(return_value=("Laptop", True))
+ agent = _make_agent(resolver)
+ _configure_transfer(transfer_mock, session_mock, address="AA:BB:CC:DD:EE:FF")
+
+ agent._authorize_push("/transfer", MagicMock(), MagicMock())
+
+ resolver.assert_called_once_with("/org/bluez/hci0", "AA:BB:CC:DD:EE:FF")
+ self.assertFalse(hasattr(agent, "_applet"))
+
+ @patch("blueman.plugins.applet.TransferService.GLib")
+ @patch("blueman.plugins.applet.TransferService.Notification")
+ @patch("blueman.plugins.applet.TransferService.Session")
+ @patch("blueman.plugins.applet.TransferService.Transfer")
+ def test_resolver_failure_falls_back_to_address(self, transfer_mock: MagicMock, session_mock: MagicMock,
+ notification_mock: MagicMock, _glib_mock: MagicMock) -> None:
+ resolver = MagicMock(side_effect=RuntimeError("no device"))
+ agent = _make_agent(resolver)
+ agent._config.__getitem__.side_effect = lambda key: False # force the confirmation path
+ _configure_transfer(transfer_mock, session_mock, address="AA:BB:CC:DD:EE:FF", size=10)
+
+ agent._authorize_push("/transfer", MagicMock(), MagicMock())
+
+ body = notification_mock.call_args.args[1]
+ self.assertIn("AA:BB:CC:DD:EE:FF", body) # falls back to the raw address as the display name
+
+
+@patch("blueman.plugins.applet.TransferService.Notification")
+class TestTransferCompleted(TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ root = Path(self._tmp.name)
+ self.dest_dir = root / "Downloads"
+ self.dest_dir.mkdir()
+ self.src = root / "incoming.bin"
+ self.src.write_text("payload")
+
+ def _plugin(self, size: int = 10) -> TransferService:
+ plugin = TransferService.__new__(TransferService)
+ plugin._agent = MagicMock()
+ plugin._agent.transfers = {"/t": {"path": self.src, "size": size, "name": "Phone"}}
+ plugin._normal_transfers = 0
+ plugin._silent_transfers = 1
+ plugin._notification = None
+ return plugin
+
+ def test_unauthorized_transfer_ignored(self, notification_mock: MagicMock) -> None:
+ plugin = self._plugin()
+ plugin._agent.transfers = {}
+ plugin._on_transfer_completed(MagicMock(), "/t", True)
+ notification_mock.assert_not_called()
+
+ def test_success_moves_file_and_notifies(self, notification_mock: MagicMock) -> None:
+ plugin = self._plugin()
+ with patch.object(TransferService, "_make_share_path", return_value=(self.dest_dir, False)):
+ plugin._on_transfer_completed(MagicMock(), "/t", True)
+ self.assertTrue((self.dest_dir / "incoming.bin").exists())
+ self.assertFalse(self.src.exists())
+ notification_mock.assert_called_once()
+ self.assertNotIn("/t", plugin._agent.transfers)
+
+ def test_failed_move_cleans_placeholder_and_decrements(self, notification_mock: MagicMock) -> None:
+ plugin = self._plugin(size=10)
+ with patch.object(TransferService, "_make_share_path", return_value=(self.dest_dir, False)), \
+ patch("blueman.plugins.applet.TransferService.shutil.move", side_effect=OSError("boom")):
+ plugin._on_transfer_completed(MagicMock(), "/t", True)
+ self.assertEqual(list(self.dest_dir.iterdir()), []) # reserved placeholder cleaned up
+ self.assertEqual(plugin._silent_transfers, 0)
+ self.assertNotIn("/t", plugin._agent.transfers)
+
+ def test_failed_move_decrements_normal_for_large_file(self, notification_mock: MagicMock) -> None:
+ plugin = self._plugin(size=400000)
+ plugin._normal_transfers = 1
+ plugin._silent_transfers = 0
+ with patch.object(TransferService, "_make_share_path", return_value=(self.dest_dir, False)), \
+ patch("blueman.plugins.applet.TransferService.shutil.move", side_effect=PermissionError):
+ plugin._on_transfer_completed(MagicMock(), "/t", False)
+ self.assertEqual(plugin._normal_transfers, 0)
+
+
+class TestTransferStarted(TestCase):
+ def _plugin(self, size: int) -> TransferService:
+ plugin = TransferService.__new__(TransferService)
+ plugin._agent = MagicMock()
+ plugin._agent.transfers = {"/t": {"path": Path("/x"), "size": size, "name": "Phone"}}
+ plugin._normal_transfers = 0
+ plugin._silent_transfers = 0
+ return plugin
+
+ def test_large_file_counts_as_normal(self) -> None:
+ plugin = self._plugin(400000)
+ plugin._on_transfer_started(MagicMock(), "/t")
+ self.assertEqual((plugin._normal_transfers, plugin._silent_transfers), (1, 0))
+
+ def test_small_file_counts_as_silent(self) -> None:
+ plugin = self._plugin(10)
+ plugin._on_transfer_started(MagicMock(), "/t")
+ self.assertEqual((plugin._normal_transfers, plugin._silent_transfers), (0, 1))
+
+ def test_unauthorized_transfer_ignored(self) -> None:
+ plugin = self._plugin(10)
+ plugin._agent = None
+ plugin._on_transfer_started(MagicMock(), "/t") # must not raise
+
+
+@patch("blueman.plugins.applet.TransferService.Notification")
+class TestSessionRemoved(TestCase):
+ def _plugin(self, silent: int, normal: int) -> TransferService:
+ plugin = TransferService.__new__(TransferService)
+ plugin._silent_transfers = silent
+ plugin._normal_transfers = normal
+ plugin._notification = None
+ return plugin
+
+ def test_no_silent_transfers_does_nothing(self, notification_mock: MagicMock) -> None:
+ plugin = self._plugin(silent=0, normal=0)
+ plugin._on_session_removed(MagicMock(), "/sess")
+ notification_mock.assert_not_called()
+
+ def test_only_silent_transfers_notifies(self, notification_mock: MagicMock) -> None:
+ plugin = self._plugin(silent=2, normal=0)
+ with patch.object(TransferService, "_make_share_path", return_value=(Path("/dl"), False)):
+ plugin._on_session_removed(MagicMock(), "/sess")
+ notification_mock.assert_called_once()
+
+ def test_mixed_transfers_notifies_more_variant(self, notification_mock: MagicMock) -> None:
+ plugin = self._plugin(silent=1, normal=1)
+ with patch.object(TransferService, "_make_share_path", return_value=(Path("/dl"), False)):
+ plugin._on_session_removed(MagicMock(), "/sess")
+ notification_mock.assert_called_once()
+
+
+class TestAgentControl(TestCase):
+ def test_cancel_closes_notification_and_raises(self) -> None:
+ from blueman.plugins.applet.TransferService import ObexErrorCanceled
+ agent = _make_agent()
+ agent._notification = MagicMock()
+ with self.assertRaises(ObexErrorCanceled):
+ agent._cancel()
+ agent._notification.close.assert_called_once()
+
+ def test_release_raises(self) -> None:
+ agent = _make_agent()
+ with self.assertRaises(Exception):
+ agent._release()
+
+
+@patch("blueman.plugins.applet.TransferService.GLib")
+class TestMakeSharePath(TestCase):
+ def _plugin(self, configured: str) -> TransferService:
+ plugin = TransferService.__new__(TransferService)
+ config = MagicMock()
+ config.__getitem__.side_effect = lambda key: configured
+ config.__setitem__ = MagicMock()
+ plugin._config = config
+ return plugin
+
+ def test_empty_config_uses_download_dir(self, glib_mock: MagicMock) -> None:
+ glib_mock.get_user_special_dir.return_value = "/dl"
+ plugin = self._plugin("")
+ path, error = plugin._make_share_path()
+ self.assertEqual(path, Path("/dl"))
+ self.assertFalse(error)
+
+ def test_invalid_dir_flags_error(self, glib_mock: MagicMock) -> None:
+ glib_mock.get_user_special_dir.return_value = "/dl"
+ plugin = self._plugin("/does/not/exist/here")
+ path, error = plugin._make_share_path()
+ self.assertEqual(path, Path("/dl"))
+ self.assertTrue(error)
+
+ def test_valid_dir_used(self, glib_mock: MagicMock) -> None:
+ glib_mock.get_user_special_dir.return_value = "/dl"
+ with tempfile.TemporaryDirectory() as tmp:
+ plugin = self._plugin(tmp)
+ path, error = plugin._make_share_path()
+ self.assertEqual(path, Path(tmp))
+ self.assertFalse(error)
+
+
+@patch("blueman.plugins.applet.TransferService.GLib")
+@patch("blueman.plugins.applet.TransferService.Notification")
+@patch("blueman.plugins.applet.TransferService.Session")
+@patch("blueman.plugins.applet.TransferService.Transfer")
+class TestAutoAccept(TestCase):
+ def test_trusted_large_file_auto_accepts_with_notification(self, transfer_mock: MagicMock, session_mock: MagicMock,
+ notification_mock: MagicMock,
+ _glib_mock: MagicMock) -> None:
+ agent = _make_agent() # default resolver -> trusted
+ _configure_transfer(transfer_mock, session_mock, address="AA:BB:CC:DD:EE:FF", size=400001)
+ ok = MagicMock()
+ agent._authorize_push("/t", ok, MagicMock())
+ ok.assert_called_once()
+ self.assertIn("/t", agent.transfers)
+ self.assertIn("AA:BB:CC:DD:EE:FF", agent._allowed_devices)
+ notification_mock.assert_called_once()
+
+
+class TestRegisterAgent(TestCase):
+ @patch("blueman.plugins.applet.TransferService.Agent")
+ def test_register_then_unregister(self, agent_cls: MagicMock) -> None:
+ plugin = TransferService.__new__(TransferService)
+ plugin._agent = None
+ plugin._register_agent()
+ agent_cls.assert_called_once_with(plugin._resolve_device)
+ agent_cls.return_value.register_at_manager.assert_called_once()
+ agent = plugin._agent
+ plugin._unregister_agent()
+ agent.unregister_from_manager.assert_called_once()
+ agent.unregister.assert_called_once()
+ self.assertIsNone(plugin._agent)
+
+
+@patch("blueman.plugins.applet.TransferService.launch")
+@patch("blueman.plugins.applet.TransferService.Notification")
+class TestOpenAction(TestCase):
+ def test_open_action_launches_xdg_open(self, notification_mock: MagicMock, launch_mock: MagicMock) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ dest_dir = Path(tmp)
+ src = dest_dir / "incoming.bin"
+ src.write_text("x")
+ plugin = TransferService.__new__(TransferService)
+ plugin._agent = MagicMock()
+ plugin._agent.transfers = {"/t": {"path": src, "size": 10, "name": "Phone"}}
+ plugin._normal_transfers = 0
+ plugin._silent_transfers = 1
+ plugin._notification = None
+ notification_mock.return_value.actions_supported = True
+ dst = dest_dir / "out"
+ dst.mkdir()
+ with patch.object(TransferService, "_make_share_path", return_value=(dst, False)):
+ plugin._on_transfer_completed(MagicMock(), "/t", True)
+ on_open = notification_mock.return_value.add_action.call_args.args[2]
+ on_open("open")
+ launch_mock.assert_called_once()
+
+
+@patch("blueman.plugins.applet.TransferService.AgentManager")
+@patch("blueman.plugins.applet.TransferService.Gio")
+@patch("blueman.plugins.applet.TransferService.DbusService.__init__", return_value=None)
+class TestAgentLifecycle(TestCase):
+ def _make(self) -> tuple:
+ with patch.object(Agent, "add_method") as add, patch.object(Agent, "register") as reg:
+ agent = Agent(lambda source, address: ("X", True))
+ return agent, add, reg
+
+ def test_init_registers_three_methods_and_state(self, _dbus: MagicMock, _gio: MagicMock,
+ _am: MagicMock) -> None:
+ agent, add, reg = self._make()
+ self.assertEqual(add.call_count, 3)
+ reg.assert_called_once()
+ self.assertEqual(agent._allowed_devices, set())
+ self.assertEqual(agent._pending_transfers, {})
+ self.assertEqual(agent.transfers, {})
+
+ def test_register_and_unregister_at_manager(self, _dbus: MagicMock, _gio: MagicMock,
+ am_mock: MagicMock) -> None:
+ agent, _add, _reg = self._make()
+ agent.register_at_manager()
+ am_mock.return_value.register_agent.assert_called_once()
+ agent.unregister_from_manager()
+ am_mock.return_value.unregister_agent.assert_called_once()
+
+
+@patch("blueman.plugins.applet.TransferService.Manager")
+@patch("blueman.plugins.applet.TransferService.Notification")
+@patch("blueman.plugins.applet.TransferService.Gio.Settings")
+class TestOnLoadClosures(TestCase):
+ def _load(self, settings_mock: MagicMock, notification_mock: MagicMock, invalid: bool) -> TransferService:
+ plugin = _make_plugin("/configured")
+ settings_mock.return_value = plugin._config
+ with patch.object(TransferService, "_make_share_path", return_value=(Path("/srv/Downloads"), invalid)):
+ plugin.on_load()
+ return plugin
+
+ def test_handlerids_are_per_instance(self, settings_mock: MagicMock, notification_mock: MagicMock,
+ _manager_mock: MagicMock) -> None:
+ plugin = self._load(settings_mock, notification_mock, invalid=False)
+ self.assertEqual(plugin._handlerids, [])
+ self.assertIsNot(plugin._handlerids, TransferService._handlerids)
+
+ def test_on_reset_clears_config_and_notification(self, settings_mock: MagicMock, notification_mock: MagicMock,
+ _manager_mock: MagicMock) -> None:
+ plugin = self._load(settings_mock, notification_mock, invalid=True)
+ on_reset = notification_mock.call_args.kwargs["actions_cb"]
+ on_reset("reset")
+ plugin._config.reset.assert_called_once_with("shared-path")
+ self.assertIsNone(plugin._notification)
+
+
+@patch("blueman.plugins.applet.TransferService.Gio")
+class TestOnUnload(TestCase):
+ def test_unwatch_and_unregister(self, gio_mock: MagicMock) -> None:
+ plugin = TransferService.__new__(TransferService)
+ plugin._watch = 42
+ agent = MagicMock()
+ plugin._agent = agent
+ plugin.on_unload()
+ gio_mock.bus_unwatch_name.assert_called_once_with(42)
+ agent.unregister_from_manager.assert_called_once()
+ self.assertIsNone(plugin._agent)
+
+ def test_noop_without_watch_or_agent(self, gio_mock: MagicMock) -> None:
+ plugin = TransferService.__new__(TransferService)
+ plugin._watch = None
+ plugin._agent = None
+ plugin.on_unload()
+ gio_mock.bus_unwatch_name.assert_not_called()
+
+
+@patch("blueman.plugins.applet.TransferService.Agent")
+@patch("blueman.plugins.applet.TransferService.Manager")
+class TestDbusNameLifecycle(TestCase):
+ def _plugin(self) -> TransferService:
+ plugin = TransferService.__new__(TransferService)
+ plugin._agent = None
+ plugin._manager = None
+ plugin._handlerids = []
+ return plugin
+
+ def test_appeared_connects_signals_and_registers(self, manager_mock: MagicMock, agent_mock: MagicMock) -> None:
+ plugin = self._plugin()
+ plugin._on_dbus_name_appeared(MagicMock(), "name", "owner")
+ self.assertEqual(len(plugin._handlerids), 3)
+ agent_mock.assert_called_once_with(plugin._resolve_device)
+
+ def test_appeared_handles_manager_failure(self, manager_mock: MagicMock, agent_mock: MagicMock) -> None:
+ from gi.repository import GLib
+ manager_mock.side_effect = GLib.Error("obex down")
+ plugin = self._plugin()
+ plugin._on_dbus_name_appeared(MagicMock(), "name", "owner")
+ self.assertEqual(plugin._handlerids, [])
+ agent_mock.assert_not_called()
+
+ def test_vanished_disconnects_and_clears(self, manager_mock: MagicMock, _agent_mock: MagicMock) -> None:
+ plugin = self._plugin()
+ manager = MagicMock()
+ plugin._manager = manager
+ plugin._handlerids = [1, 2, 3]
+ agent = MagicMock()
+ plugin._agent = agent
+ plugin._on_dbus_name_vanished(MagicMock(), "name")
+ self.assertEqual(manager.disconnect.call_count, 3)
+ self.assertIsNone(plugin._manager)
+ self.assertEqual(plugin._handlerids, [])
+ agent.unregister.assert_called_once()
+ self.assertIsNone(plugin._agent)
+
+ def test_vanished_noop_when_idle(self, manager_mock: MagicMock, _agent_mock: MagicMock) -> None:
+ plugin = self._plugin()
+ plugin._on_dbus_name_vanished(MagicMock(), "name") # must not raise
+
+
+class TestReserveDestinationExhaustion(TestCase):
+ def test_raises_when_no_free_candidate(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ d = Path(tmp)
+ now = datetime(2020, 1, 2, 3, 4, 5)
+ stamp = "20200102030405"
+ with patch("blueman.plugins.applet.TransferService._MAX_DESTINATION_ATTEMPTS", 2):
+ (d / "f").write_text("")
+ (d / f"{stamp}_f").write_text("")
+ (d / f"{stamp}_1_f").write_text("")
+ with self.assertRaises(FileExistsError):
+ reserve_destination(d, "f", now)
+
+
+@patch("blueman.plugins.applet.TransferService.GLib")
+class TestMakeSharePathReset(TestCase):
+ def test_config_equal_to_default_is_reset(self, glib_mock: MagicMock) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ glib_mock.get_user_special_dir.return_value = tmp
+ plugin = TransferService.__new__(TransferService)
+ config = MagicMock()
+ config.__getitem__.side_effect = lambda key: tmp
+ config.__setitem__ = MagicMock()
+ plugin._config = config
+ path, error = plugin._make_share_path()
+ self.assertEqual(path, Path(tmp))
+ self.assertFalse(error)
+ config.__setitem__.assert_called_with("shared-path", "")
+
+
+@patch("blueman.plugins.applet.TransferService.Notification")
+class TestCompletionRenameAndReset(TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ root = Path(self._tmp.name)
+ self.dest_dir = root / "Downloads"
+ self.dest_dir.mkdir()
+ self.src = root / "incoming.bin"
+ self.src.write_text("payload")
+
+ def test_collision_renames_with_timestamp(self, _notification_mock: MagicMock) -> None:
+ (self.dest_dir / "incoming.bin").write_text("pre-existing")
+ plugin = TransferService.__new__(TransferService)
+ plugin._agent = MagicMock()
+ plugin._agent.transfers = {"/t": {"path": self.src, "size": 10, "name": "Phone"}}
+ plugin._normal_transfers = 0
+ plugin._silent_transfers = 1
+ plugin._notification = None
+ with patch.object(TransferService, "_make_share_path", return_value=(self.dest_dir, False)):
+ plugin._on_transfer_completed(MagicMock(), "/t", True)
+ moved = [p.name for p in self.dest_dir.iterdir()]
+ self.assertIn("incoming.bin", moved) # the pre-existing file
+ self.assertTrue(any(n.endswith("_incoming.bin") for n in moved)) # the renamed arrival
+
+
+@patch("blueman.plugins.applet.TransferService.Notification")
+class TestSessionCounterReset(TestCase):
+ def _plugin(self, silent: int, normal: int) -> TransferService:
+ plugin = TransferService.__new__(TransferService)
+ plugin._silent_transfers = silent
+ plugin._normal_transfers = normal
+ plugin._notification = None
+ return plugin
+
+ def test_counters_reset_after_summary(self, _notification_mock: MagicMock) -> None:
+ plugin = self._plugin(silent=2, normal=1)
+ with patch.object(TransferService, "_make_share_path", return_value=(Path("/dl"), False)):
+ plugin._on_session_removed(MagicMock(), "/sess")
+ self.assertEqual(plugin._silent_transfers, 0)
+ self.assertEqual(plugin._normal_transfers, 0)
+
+
+@patch("blueman.plugins.applet.TransferService.GLib")
+class TestMakeSharePathXdgMissing(TestCase):
+ def test_falls_back_to_home_when_xdg_unavailable(self, glib_mock: MagicMock) -> None:
+ glib_mock.get_user_special_dir.return_value = None
+ plugin = TransferService.__new__(TransferService)
+ config = MagicMock()
+ config.__getitem__.side_effect = lambda key: "" # no configured path
+ config.__setitem__ = MagicMock()
+ plugin._config = config
+ path, error = plugin._make_share_path()
+ self.assertEqual(path, Path("~").expanduser())
+ self.assertFalse(error)
diff --git a/test/test_functions.py b/test/test_functions.py
new file mode 100644
index 000000000..a5e47c8c5
--- /dev/null
+++ b/test/test_functions.py
@@ -0,0 +1,311 @@
+import logging
+import os
+import tempfile
+from pathlib import Path
+from unittest import TestCase
+from unittest.mock import MagicMock, patch
+
+from gi.repository import GLib
+
+from blueman.Constants import BIN_DIR
+from blueman.Functions import (
+ adapter_path_to_name,
+ check_bluetooth_status,
+ create_logger,
+ create_parser,
+ format_bytes,
+ have,
+ launch,
+ parse_os_release,
+ set_proc_title,
+)
+from blueman.main.DBusProxies import DBusProxyFailed
+
+
+class TestFormatBytes(TestCase):
+ def test_zero(self) -> None:
+ self.assertEqual(format_bytes(0), (0.0, "B"))
+
+ def test_sub_kilobyte(self) -> None:
+ self.assertEqual(format_bytes(512), (512.0, "B"))
+ self.assertEqual(format_bytes(1023), (1023.0, "B"))
+
+ def test_kilobyte_boundary(self) -> None:
+ # Regression: exact 1024 must be 1.0 KB, not a fraction of a GB.
+ self.assertEqual(format_bytes(1024), (1.0, "KB"))
+
+ def test_megabyte_boundary(self) -> None:
+ self.assertEqual(format_bytes(1024 * 1024), (1.0, "MB"))
+
+ def test_gigabyte_boundary(self) -> None:
+ self.assertEqual(format_bytes(1024 * 1024 * 1024), (1.0, "GB"))
+
+ def test_mid_band_values(self) -> None:
+ self.assertEqual(format_bytes(1536), (1.5, "KB"))
+ self.assertEqual(format_bytes(1024 * 1024 * 3), (3.0, "MB"))
+
+ def test_huge_value(self) -> None:
+ ret, suffix = format_bytes(5 * 1024 ** 4)
+ self.assertEqual(suffix, "GB")
+ self.assertEqual(ret, 5 * 1024)
+
+ def test_accepts_float_and_int(self) -> None:
+ self.assertEqual(format_bytes(2048.0), (2.0, "KB"))
+ self.assertEqual(format_bytes(2048), (2.0, "KB"))
+
+
+class TestAdapterPathToName(TestCase):
+ def test_normal_path(self) -> None:
+ self.assertEqual(adapter_path_to_name("/org/bluez/hci0"), "hci0")
+
+ def test_none_and_empty(self) -> None:
+ self.assertIsNone(adapter_path_to_name(None))
+ self.assertIsNone(adapter_path_to_name(""))
+
+ def test_no_hci(self) -> None:
+ self.assertIsNone(adapter_path_to_name("/org/bluez"))
+ self.assertIsNone(adapter_path_to_name("no-match"))
+
+ def test_case_sensitive(self) -> None:
+ # The pattern matches lowercase "hci" only.
+ self.assertIsNone(adapter_path_to_name("HCI0"))
+
+ def test_trailing_segments(self) -> None:
+ # Device sub-paths still resolve to the adapter name.
+ self.assertEqual(adapter_path_to_name("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"), "hci0")
+
+ def test_zero_digits_allowed(self) -> None:
+ # The `[0-9]*` quantifier permits an "hci" with no index.
+ self.assertEqual(adapter_path_to_name("/org/bluez/hci"), "hci")
+ self.assertEqual(adapter_path_to_name("hci"), "hci")
+
+ def test_greedy_picks_last_occurrence(self) -> None:
+ # Greedy `.*` consumes up to the final "hci" match.
+ self.assertEqual(adapter_path_to_name("/org/bluez/hci0hci1"), "hci1")
+
+ def test_embedded_in_other_text(self) -> None:
+ self.assertEqual(adapter_path_to_name("prefix-hci99-suffix"), "hci99")
+
+
+class TestParseOsRelease(TestCase):
+ def _parse(self, content: str) -> dict[str, str]:
+ with tempfile.NamedTemporaryFile("w", suffix="os-release", delete=False) as f:
+ f.write(content)
+ path = Path(f.name)
+ try:
+ return parse_os_release(path)
+ finally:
+ path.unlink()
+
+ def test_basic_keys(self) -> None:
+ result = self._parse('NAME="Foo"\nVERSION="1.0"\n')
+ self.assertEqual(result, {"NAME": "Foo", "VERSION": "1.0"})
+
+ def test_value_with_equals_sign(self) -> None:
+ # Regression (data-3): a quoted value containing "=" must survive.
+ result = self._parse('PRETTY_NAME="Name=Variant"\n')
+ self.assertEqual(result["PRETTY_NAME"], "Name=Variant")
+
+ def test_unquoted_value(self) -> None:
+ self.assertEqual(self._parse("ID=arch\n"), {"ID": "arch"})
+
+ def test_comments_and_blank_lines_skipped(self) -> None:
+ result = self._parse("# comment\n\n \nID=foo\n")
+ self.assertEqual(result, {"ID": "foo"})
+
+ def test_line_without_equals_skipped(self) -> None:
+ result = self._parse("garbage line\nID=foo\n")
+ self.assertEqual(result, {"ID": "foo"})
+
+ def test_missing_file_returns_empty(self) -> None:
+ self.assertEqual(parse_os_release(Path("/nonexistent/os-release")), {})
+
+
+class TestHave(TestCase):
+ @patch("blueman.Functions.shutil.which", return_value="/usr/bin/dhclient")
+ def test_found_returns_path(self, which: object) -> None:
+ result = have("dhclient")
+ self.assertEqual(result, Path("/usr/bin/dhclient"))
+
+ @patch("blueman.Functions.shutil.which", return_value=None)
+ def test_not_found_returns_none(self, which: object) -> None:
+ self.assertIsNone(have("nonexistent-binary"))
+
+ @patch.dict(os.environ, {"PATH": "/usr/bin"}, clear=True)
+ @patch("blueman.Functions.shutil.which", return_value=None)
+ def test_appends_sbin_dirs(self, which: object) -> None:
+ have("dhcpcd")
+ used_path = which.call_args.kwargs["path"]
+ parts = used_path.split(os.pathsep)
+ self.assertIn("/usr/bin", parts)
+ self.assertIn("/sbin", parts)
+ self.assertIn("/usr/sbin", parts)
+
+ @patch.dict(os.environ, {"PATH": "/sbin:/usr/sbin:/usr/bin"}, clear=True)
+ @patch("blueman.Functions.shutil.which", return_value=None)
+ def test_does_not_duplicate_existing_sbin(self, which: object) -> None:
+ have("udhcpc")
+ used_path = which.call_args.kwargs["path"]
+ self.assertEqual(used_path.split(os.pathsep).count("/sbin"), 1)
+ self.assertEqual(used_path.split(os.pathsep).count("/usr/sbin"), 1)
+
+
+class TestCreateLogger(TestCase):
+ def setUp(self) -> None:
+ root = logging.getLogger(None)
+ self._saved_handlers = root.handlers[:]
+ self._saved_name = root.name
+ self._saved_level = root.level
+
+ def tearDown(self) -> None:
+ root = logging.getLogger(None)
+ root.handlers[:] = self._saved_handlers
+ root.name = self._saved_name
+ root.level = self._saved_level
+
+ @patch("blueman.Functions.logging.handlers.SysLogHandler")
+ def test_syslog_handler_added_when_available(self, handler_cls: MagicMock) -> None:
+ handler_cls.return_value = logging.NullHandler()
+ create_logger(logging.INFO, "blueman-test", syslog=True)
+ handler_cls.assert_called_once_with(address="/dev/log")
+
+ @patch("blueman.Functions.logging.handlers.SysLogHandler", side_effect=OSError)
+ def test_falls_back_to_stderr_when_dev_log_missing(self, handler_cls: MagicMock) -> None:
+ # Must not raise when /dev/log is unavailable, and add no syslog handler.
+ before = len(logging.getLogger(None).handlers)
+ logger = create_logger(logging.INFO, "blueman-test", syslog=True)
+ handler_cls.assert_called_once_with(address="/dev/log")
+ self.assertLessEqual(len(logger.handlers), before + 1) # only basicConfig's stderr handler, if any
+
+ @patch("blueman.Functions.logging.handlers.SysLogHandler")
+ def test_no_syslog_handler_when_disabled(self, handler_cls: MagicMock) -> None:
+ create_logger(logging.INFO, "blueman-test", syslog=False)
+ handler_cls.assert_not_called()
+
+
+class TestCreateParser(TestCase):
+ def test_default_loglevel(self) -> None:
+ args = create_parser().parse_args([])
+ self.assertEqual(args.LEVEL, "warning")
+
+ def test_valid_loglevel_lowercased(self) -> None:
+ args = create_parser().parse_args(["--loglevel", "DEBUG"])
+ self.assertEqual(args.LEVEL, "debug")
+
+ def test_invalid_loglevel_rejected(self) -> None:
+ # argparse exits with status 2 on an out-of-choices value.
+ with self.assertRaises(SystemExit):
+ create_parser().parse_args(["--loglevel", "verbose"])
+
+ def test_loglevel_has_help(self) -> None:
+ for action in create_parser()._actions:
+ if action.dest == "LEVEL":
+ self.assertTrue(action.help)
+ self.assertEqual(
+ set(action.choices), {"debug", "info", "warning", "error", "critical"})
+ break
+ else:
+ self.fail("--loglevel argument not registered")
+
+ def test_syslog_flag(self) -> None:
+ self.assertTrue(create_parser().parse_args(["--syslog"]).syslog)
+ self.assertFalse(create_parser().parse_args([]).syslog)
+
+ def test_loglevel_can_be_disabled(self) -> None:
+ parser = create_parser(loglevel=False)
+ self.assertFalse(any(a.dest == "LEVEL" for a in parser._actions))
+
+
+class TestCheckBluetoothStatus(TestCase):
+ @patch("blueman.Functions.AppletService", side_effect=DBusProxyFailed)
+ def test_logs_and_exits_when_applet_missing(self, applet: MagicMock) -> None:
+ exitfunc = MagicMock()
+ with self.assertLogs(level=logging.ERROR) as logs:
+ check_bluetooth_status("msg", exitfunc)
+ exitfunc.assert_called_once_with()
+ self.assertTrue(any("applet needs to be running" in m for m in logs.output))
+
+ @patch("blueman.Functions.AppletPowerManagerService")
+ @patch("blueman.Functions.AppletService")
+ def test_returns_when_powermanager_plugin_absent(
+ self, applet: MagicMock, power: MagicMock
+ ) -> None:
+ applet.return_value.QueryPlugins.return_value = []
+ exitfunc = MagicMock()
+ check_bluetooth_status("msg", exitfunc)
+ exitfunc.assert_not_called()
+
+
+class TestSetProcTitle(TestCase):
+ @patch("blueman.Functions.sys.platform", "darwin")
+ @patch("blueman.Functions.cdll")
+ def test_noop_on_non_linux(self, cdll: MagicMock) -> None:
+ self.assertEqual(set_proc_title("blueman"), 0)
+ cdll.LoadLibrary.assert_not_called()
+
+ @patch("blueman.Functions.sys.platform", "linux")
+ @patch("blueman.Functions.cdll")
+ def test_calls_prctl_on_linux(self, cdll: MagicMock) -> None:
+ cdll.LoadLibrary.return_value.prctl.return_value = 0
+ self.assertEqual(set_proc_title("blueman"), 0)
+ cdll.LoadLibrary.return_value.prctl.assert_called_once()
+
+ @patch("blueman.Functions.sys.platform", "linux")
+ @patch("blueman.Functions.cdll")
+ def test_returns_minus_one_when_libc_unavailable(self, cdll: MagicMock) -> None:
+ cdll.LoadLibrary.side_effect = OSError
+ with self.assertLogs(level=logging.ERROR):
+ self.assertEqual(set_proc_title("blueman"), -1)
+
+
+@patch("blueman.Functions.Gtk.get_current_event_time", return_value=0)
+@patch("blueman.Functions.Gio.File.new_for_commandline_arg")
+@patch("blueman.Functions.Gio.AppInfo.create_from_commandline")
+class TestLaunch(TestCase):
+ @staticmethod
+ def _command_line(create: MagicMock) -> str:
+ return create.call_args.args[0]
+
+ def test_legacy_string_form_unquoted(
+ self, create: MagicMock, _new_file: MagicMock, _evt: MagicMock
+ ) -> None:
+ create.return_value.launch.return_value = True
+ self.assertTrue(launch("blueman-services"))
+ self.assertEqual(self._command_line(create), (BIN_DIR / "blueman-services").as_posix())
+
+ def test_argv_form_quotes_each_token(
+ self, create: MagicMock, _new_file: MagicMock, _evt: MagicMock
+ ) -> None:
+ create.return_value.launch.return_value = True
+ launch("blueman-sendto", args=["--delete", "--device=AA:BB:CC:DD:EE:FF"])
+ expected = " ".join(
+ GLib.shell_quote(t) for t in (
+ (BIN_DIR / "blueman-sendto").as_posix(),
+ "--delete",
+ "--device=AA:BB:CC:DD:EE:FF",
+ )
+ )
+ self.assertEqual(self._command_line(create), expected)
+
+ def test_argv_form_neutralizes_shell_metacharacters(
+ self, create: MagicMock, _new_file: MagicMock, _evt: MagicMock
+ ) -> None:
+ create.return_value.launch.return_value = True
+ hostile = "foo; rm -rf ~"
+ launch("blueman-sendto", args=[hostile])
+ command_line = self._command_line(create)
+ # The hostile argument must survive as a single shell-quoted token.
+ self.assertIn(GLib.shell_quote(hostile), command_line)
+
+ def test_returns_launch_result(
+ self, create: MagicMock, _new_file: MagicMock, _evt: MagicMock
+ ) -> None:
+ create.return_value.launch.return_value = False
+ self.assertFalse(launch("blueman-services"))
+
+ def test_paths_become_gio_files(
+ self, create: MagicMock, new_file: MagicMock, _evt: MagicMock
+ ) -> None:
+ create.return_value.launch.return_value = True
+ launch("xdg-open", paths=["/tmp/a", "/tmp/b"], system=True)
+ self.assertEqual(new_file.call_count, 2)