Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions bitcoin_usb/usb_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def get_devices(self, slow_hwi_listing=False) -> list[dict[str, Any]]:
def get_device(self, slow_hwi_listing=False) -> dict[str, Any] | None:
"Returns the found devices WITHOUT unlocking them first. Misses the fingerprints"
bluetooth_scan_callback: Callable[[], list[dict[str, Any]]] | None = None
if self._is_bluetooth_scan_supported():
if self.enable_bluetooth:
bluetooth_scan_callback = self.get_bluetooth_devices

dialog = DeviceDialog(
Expand All @@ -143,14 +143,17 @@ def get_device(self, slow_hwi_listing=False) -> dict[str, Any] | None:
def get_bluetooth_devices(self) -> list[dict[str, Any]]:
if not self.enable_bluetooth:
raise RuntimeError(self.tr("Bluetooth support is disabled by configuration."))
if not self._is_bluetooth_scan_supported():
should_retry_probe = platform.system() == "Darwin"
if not self._is_bluetooth_scan_supported(force_refresh=should_retry_probe):
if should_retry_probe and self._is_bluetooth_scan_supported(force_refresh=True):
return _run_ble_operation(self._discover_bluetooth_devices)
raise RuntimeError(self.tr("Bluetooth scanning is not available in this environment."))
return _run_ble_operation(self._discover_bluetooth_devices)

def _is_bluetooth_scan_supported(self) -> bool:
def _is_bluetooth_scan_supported(self, force_refresh: bool = False) -> bool:
if not self.enable_bluetooth:
return False
if self._bluetooth_scan_supported is None:
if force_refresh or self._bluetooth_scan_supported is None:
self._bluetooth_scan_supported = can_scan_bluetooth_devices()
return self._bluetooth_scan_supported

Expand Down
87 changes: 87 additions & 0 deletions tests/test_bluetooth_scan_support.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from bitcoin_usb import usb_gui
from bitcoin_usb.usb_gui import USBGui


def test_can_scan_bluetooth_devices_uses_ble_operation_wrapper(monkeypatch) -> None:
Expand Down Expand Up @@ -31,3 +32,89 @@ def raise_runtime_error(_operation):
monkeypatch.setattr(usb_gui, "_run_ble_operation", raise_runtime_error)

assert usb_gui.can_scan_bluetooth_devices() is False


def test_get_bluetooth_devices_retries_support_probe_on_macos(monkeypatch) -> None:
gui = USBGui(network=object(), loop_in_thread=object())
probe_calls: list[bool] = []

def fake_probe() -> bool:
probe_calls.append(True)
return len(probe_calls) > 1

monkeypatch.setattr(usb_gui.platform, "system", lambda: "Darwin")
monkeypatch.setattr(usb_gui, "can_scan_bluetooth_devices", fake_probe)
monkeypatch.setattr(usb_gui, "_run_ble_operation", lambda operation: operation())
monkeypatch.setattr(gui, "_discover_bluetooth_devices", lambda: [{"transport": "bluetooth"}])

assert gui.get_bluetooth_devices() == [{"transport": "bluetooth"}]
assert len(probe_calls) == 2
assert gui._bluetooth_scan_supported is True


def test_get_device_exposes_bluetooth_scan_callback_when_enabled(monkeypatch) -> None:
gui = USBGui(network=object(), loop_in_thread=object(), enable_bluetooth=True)
captured: dict[str, object] = {}
callback = lambda: []
monkeypatch.setattr(gui, "get_bluetooth_devices", callback)

class _FakeDialog:
def __init__(
self,
parent,
network,
usb_scan_callback,
bluetooth_scan_callback,
install_udev_callback,
autoselect_if_1_device,
):
_ = parent
_ = network
_ = usb_scan_callback
_ = install_udev_callback
_ = autoselect_if_1_device
captured["bluetooth_scan_callback"] = bluetooth_scan_callback

def exec(self) -> bool:
return False

def get_selected_device(self):
raise AssertionError("Not expected when dialog is rejected")

monkeypatch.setattr(usb_gui, "DeviceDialog", _FakeDialog)

assert gui.get_device() is None
assert captured["bluetooth_scan_callback"] is callback


def test_get_device_hides_bluetooth_scan_callback_when_disabled(monkeypatch) -> None:
gui = USBGui(network=object(), loop_in_thread=object(), enable_bluetooth=False)
captured: dict[str, object] = {}

class _FakeDialog:
def __init__(
self,
parent,
network,
usb_scan_callback,
bluetooth_scan_callback,
install_udev_callback,
autoselect_if_1_device,
):
_ = parent
_ = network
_ = usb_scan_callback
_ = install_udev_callback
_ = autoselect_if_1_device
captured["bluetooth_scan_callback"] = bluetooth_scan_callback

def exec(self) -> bool:
return False

def get_selected_device(self):
raise AssertionError("Not expected when dialog is rejected")

monkeypatch.setattr(usb_gui, "DeviceDialog", _FakeDialog)

assert gui.get_device() is None
assert captured["bluetooth_scan_callback"] is None