columba/python/test_android_ble_driver.py
torlando-tech 8eea98b153 fix: restore reticulum_wrapper module in test tearDown to prevent isolation issues
The TestAndroidBLEDriverKotlinBridge tests were replacing
sys.modules['reticulum_wrapper'] with a MagicMock but not restoring
the original module. This corrupted the module state for later tests
that use @patch('reticulum_wrapper.RNS'), causing 210 tests to fail
when run in alphabetical order (CI) but pass when run individually.

Fix: Save the original reticulum_wrapper module in setUp and restore
it in tearDown to ensure proper test isolation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 16:54:30 -05:00

2944 lines
112 KiB
Python

"""
Test suite for Android BLE Driver
Tests the race condition handling in AndroidBLEDriver where data may arrive
from an address with a pending identity but no completed connection.
"""
import sys
import os
import unittest
from unittest.mock import Mock, MagicMock, patch
from enum import Enum, auto
import threading
# Add parent directory to path to import modules
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Mock RNS before importing the driver
mock_rns = MagicMock()
mock_rns.LOG_DEBUG = 5
mock_rns.LOG_INFO = 4
mock_rns.LOG_WARNING = 3
mock_rns.LOG_ERROR = 2
mock_rns.LOG_EXTREME = 6
mock_rns.log = MagicMock()
sys.modules['RNS'] = mock_rns
# Create mock DriverState enum
class MockDriverState(Enum):
IDLE = auto()
SCANNING = auto()
ADVERTISING = auto()
CONNECTING = auto()
# Mock bluetooth_driver module
mock_bluetooth_driver = MagicMock()
mock_bluetooth_driver.DriverState = MockDriverState
mock_bluetooth_driver.BLEDriverInterface = MagicMock()
mock_bluetooth_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bluetooth_driver
class MockAndroidBLEDriver:
"""
Test harness that mimics AndroidBLEDriver structure.
This extracts the relevant logic from AndroidBLEDriver._handle_data_received
for testing the race condition fix.
"""
def __init__(self):
self._connected_peers = []
self._peer_roles = {}
self._peer_mtus = {}
self._pending_identities = {}
self._identity_lock = threading.Lock()
# Callbacks
self.on_device_connected = None
self.on_mtu_negotiated = None
self.on_data_received = None
def _handle_data_received(self, address: str, data: bytes):
"""Handle data received - mirrors the fix in android_ble_driver.py."""
try:
# Check if this address has a pending identity but never got onConnected
if address not in self._connected_peers:
with self._identity_lock:
pending_identity = self._pending_identities.get(address)
if pending_identity:
# Finalize connection with pending identity
self._connected_peers.append(address)
self._peer_roles[address] = "peripheral"
# Remove from pending before callback
del self._pending_identities[address]
# Call on_device_connected to create identity mappings
if self.on_device_connected:
self.on_device_connected(address, pending_identity)
# Also call on_mtu_negotiated to create reassembler
mtu = self._peer_mtus.get(address, 23)
if self.on_mtu_negotiated:
self.on_mtu_negotiated(address, mtu)
if self.on_data_received:
self.on_data_received(address, data)
except Exception as e:
pass # Error handling
class TestAndroidBLEDriverRaceCondition(unittest.TestCase):
"""Test race condition handling when data arrives before connection completes."""
def setUp(self):
"""Set up test fixtures."""
self.driver = MockAndroidBLEDriver()
# Set up mock callbacks
self.driver.on_device_connected = Mock()
self.driver.on_mtu_negotiated = Mock()
self.driver.on_data_received = Mock()
def test_data_from_unknown_address_without_pending_identity(self):
"""
Test that data from unknown address without pending identity is passed through.
When data arrives from an address that:
- Is NOT in connected_peers
- Does NOT have a pending identity
The driver should NOT call on_device_connected, just pass data through.
"""
address = "AA:BB:CC:DD:EE:FF"
data = b"test data"
self.driver._handle_data_received(address, data)
# Should not trigger connection callbacks
self.driver.on_device_connected.assert_not_called()
self.driver.on_mtu_negotiated.assert_not_called()
# Should still pass data through
self.driver.on_data_received.assert_called_once_with(address, data)
def test_data_from_connected_peer(self):
"""
Test that data from connected peer is passed through normally.
When data arrives from an address that IS in connected_peers,
the driver should just pass data through without any connection logic.
"""
address = "AA:BB:CC:DD:EE:FF"
data = b"test data"
# Mark peer as connected
self.driver._connected_peers.append(address)
self.driver._handle_data_received(address, data)
# Should not trigger connection callbacks (already connected)
self.driver.on_device_connected.assert_not_called()
self.driver.on_mtu_negotiated.assert_not_called()
# Should pass data through
self.driver.on_data_received.assert_called_once_with(address, data)
def test_data_from_address_with_pending_identity(self):
"""
Test that data from address with pending identity triggers connection finalization.
This is the key race condition fix: When data arrives from an address that:
- Is NOT in connected_peers
- HAS a pending identity
The driver should:
1. Add to connected_peers
2. Call on_device_connected with the pending identity
3. Call on_mtu_negotiated to create reassembler
4. Pass data through to on_data_received
"""
address = "AA:BB:CC:DD:EE:FF"
identity = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10"
data = b"test data"
# Set up pending identity
self.driver._pending_identities[address] = identity
self.driver._handle_data_received(address, data)
# Should trigger connection finalization
self.driver.on_device_connected.assert_called_once_with(address, identity)
self.driver.on_mtu_negotiated.assert_called_once()
# Check MTU callback was called with address and default MTU (23)
mtu_call_args = self.driver.on_mtu_negotiated.call_args
self.assertEqual(mtu_call_args[0][0], address)
self.assertEqual(mtu_call_args[0][1], 23) # Default BLE 4.0 MTU
# Should pass data through
self.driver.on_data_received.assert_called_once_with(address, data)
# Should be added to connected peers
self.assertIn(address, self.driver._connected_peers)
self.assertEqual(self.driver._peer_roles[address], "peripheral")
# Should be removed from pending identities
self.assertNotIn(address, self.driver._pending_identities)
def test_data_from_address_with_pending_identity_uses_cached_mtu(self):
"""
Test that MTU from cache is used when available.
If we have a cached MTU for this address (from earlier negotiation),
the driver should use that MTU value instead of the default.
"""
address = "AA:BB:CC:DD:EE:FF"
identity = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10"
data = b"test data"
cached_mtu = 512
# Set up pending identity and cached MTU
self.driver._pending_identities[address] = identity
self.driver._peer_mtus[address] = cached_mtu
self.driver._handle_data_received(address, data)
# Check MTU callback was called with cached MTU
mtu_call_args = self.driver.on_mtu_negotiated.call_args
self.assertEqual(mtu_call_args[0][1], cached_mtu)
def test_pending_identity_consumed_only_once(self):
"""
Test that pending identity is removed after first use.
If data arrives twice from same address with pending identity,
only the first time should trigger connection finalization.
"""
address = "AA:BB:CC:DD:EE:FF"
identity = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10"
data1 = b"first data"
data2 = b"second data"
# Set up pending identity
self.driver._pending_identities[address] = identity
# First data arrival - should trigger connection
self.driver._handle_data_received(address, data1)
# Reset mocks
self.driver.on_device_connected.reset_mock()
self.driver.on_mtu_negotiated.reset_mock()
# Second data arrival - should NOT trigger connection (already connected)
self.driver._handle_data_received(address, data2)
# Should not trigger connection callbacks second time
self.driver.on_device_connected.assert_not_called()
self.driver.on_mtu_negotiated.assert_not_called()
class TestAndroidBLEDriverCallbackOrdering(unittest.TestCase):
"""Test that callbacks are called in correct order."""
def setUp(self):
"""Set up test fixtures."""
self.driver = MockAndroidBLEDriver()
# Track call order
self.call_order = []
def track_connected(addr, identity):
self.call_order.append(('connected', addr))
def track_mtu(addr, mtu):
self.call_order.append(('mtu', addr, mtu))
def track_data(addr, data):
self.call_order.append(('data', addr, len(data)))
self.driver.on_device_connected = track_connected
self.driver.on_mtu_negotiated = track_mtu
self.driver.on_data_received = track_data
def test_callback_order_for_pending_identity(self):
"""
Test that callbacks are called in correct order for pending identity case.
Order must be:
1. on_device_connected (creates identity mappings)
2. on_mtu_negotiated (creates reassembler)
3. on_data_received (processes data with reassembler)
"""
address = "AA:BB:CC:DD:EE:FF"
identity = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10"
data = b"test data"
self.driver._pending_identities[address] = identity
self.driver._handle_data_received(address, data)
# Verify order
self.assertEqual(len(self.call_order), 3)
self.assertEqual(self.call_order[0][0], 'connected')
self.assertEqual(self.call_order[1][0], 'mtu')
self.assertEqual(self.call_order[2][0], 'data')
class MockBLEInterface:
"""
Test harness that mimics BLEInterface structure for testing address change callbacks.
This tests the fix for dual connection deduplication where Python wasn't notified
when Kotlin changed the address mapping during deduplication.
"""
def __init__(self):
self.identity_to_address = {}
self.address_to_identity = {}
self.fragmenters = {}
self.reassemblers = {}
self.frag_lock = threading.Lock()
def _get_fragmenter_key(self, peer_identity, address):
"""Generate key for fragmenter/reassembler lookup."""
if peer_identity:
return f"{peer_identity.hex()[:8]}_{address}"
return address
def _address_changed_callback(self, old_address: str, new_address: str, identity_hash: str):
"""
Handle address change during dual connection deduplication.
When Kotlin deduplicates a dual connection (same identity connected as both
central and peripheral), it closes one direction and notifies Python via
this callback so Python can update its address mappings.
Args:
old_address: The address that was closed/removed
new_address: The address that remains active
identity_hash: The 32-char hex identity hash for this peer
"""
# Update identity_to_address mapping
if identity_hash in self.identity_to_address:
self.identity_to_address[identity_hash] = new_address
# Update address_to_identity mapping
peer_identity = self.address_to_identity.get(old_address)
if peer_identity:
self.address_to_identity[new_address] = peer_identity
# Keep old mapping for fallback resolution during transition
# Update fragmenter/reassembler keys
if peer_identity:
old_key = self._get_fragmenter_key(peer_identity, old_address)
new_key = self._get_fragmenter_key(peer_identity, new_address)
with self.frag_lock:
if old_key in self.fragmenters:
self.fragmenters[new_key] = self.fragmenters.pop(old_key)
if old_key in self.reassemblers:
self.reassemblers[new_key] = self.reassemblers.pop(old_key)
class TestBLEInterfaceAddressChangedCallback(unittest.TestCase):
"""
Test _address_changed_callback which handles address changes during
dual connection deduplication.
When Kotlin deduplicates dual connections (same identity connected as both
central and peripheral), it closes one direction and notifies Python via
this callback so Python can update its address mappings.
"""
def setUp(self):
"""Set up test fixtures."""
self.interface = MockBLEInterface()
def test_address_changed_updates_identity_to_address_mapping(self):
"""
Test that _address_changed_callback updates identity_to_address mapping.
When address changes from old to new, the identity_to_address map
should point to the new address.
"""
old_address = "11:22:33:44:55:66"
new_address = "AA:BB:CC:DD:EE:FF"
identity_hash = "ab5609dfffb33b21a102e1ff81196be5"
peer_identity = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
# Set up initial state
self.interface.identity_to_address[identity_hash] = old_address
self.interface.address_to_identity[old_address] = peer_identity
# Call the callback (this should fail - method doesn't exist yet)
self.interface._address_changed_callback(old_address, new_address, identity_hash)
# Verify mapping updated
self.assertEqual(self.interface.identity_to_address[identity_hash], new_address)
def test_address_changed_updates_address_to_identity_mapping(self):
"""
Test that _address_changed_callback updates address_to_identity mapping.
The new address should map to the same peer identity.
"""
old_address = "11:22:33:44:55:66"
new_address = "AA:BB:CC:DD:EE:FF"
identity_hash = "ab5609dfffb33b21a102e1ff81196be5"
peer_identity = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
# Set up initial state
self.interface.identity_to_address[identity_hash] = old_address
self.interface.address_to_identity[old_address] = peer_identity
# Call the callback
self.interface._address_changed_callback(old_address, new_address, identity_hash)
# Verify new address maps to peer identity
self.assertEqual(self.interface.address_to_identity[new_address], peer_identity)
def test_address_changed_updates_fragmenter_keys(self):
"""
Test that _address_changed_callback updates fragmenter keys.
Fragmenters are keyed by (identity_hash, address). When address changes,
the fragmenter should be accessible via the new key.
"""
old_address = "11:22:33:44:55:66"
new_address = "AA:BB:CC:DD:EE:FF"
identity_hash = "ab5609dfffb33b21a102e1ff81196be5"
peer_identity = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
mock_fragmenter = Mock()
# Set up initial state
self.interface.identity_to_address[identity_hash] = old_address
self.interface.address_to_identity[old_address] = peer_identity
old_key = self.interface._get_fragmenter_key(peer_identity, old_address)
self.interface.fragmenters[old_key] = mock_fragmenter
# Call the callback
self.interface._address_changed_callback(old_address, new_address, identity_hash)
# Verify fragmenter is accessible via new key
new_key = self.interface._get_fragmenter_key(peer_identity, new_address)
self.assertIn(new_key, self.interface.fragmenters)
self.assertEqual(self.interface.fragmenters[new_key], mock_fragmenter)
def test_address_changed_updates_reassembler_keys(self):
"""
Test that _address_changed_callback updates reassembler keys.
Reassemblers are keyed by (identity_hash, address). When address changes,
the reassembler should be accessible via the new key.
"""
old_address = "11:22:33:44:55:66"
new_address = "AA:BB:CC:DD:EE:FF"
identity_hash = "ab5609dfffb33b21a102e1ff81196be5"
peer_identity = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
mock_reassembler = Mock()
# Set up initial state
self.interface.identity_to_address[identity_hash] = old_address
self.interface.address_to_identity[old_address] = peer_identity
old_key = self.interface._get_fragmenter_key(peer_identity, old_address)
self.interface.reassemblers[old_key] = mock_reassembler
# Call the callback
self.interface._address_changed_callback(old_address, new_address, identity_hash)
# Verify reassembler is accessible via new key
new_key = self.interface._get_fragmenter_key(peer_identity, new_address)
self.assertIn(new_key, self.interface.reassemblers)
self.assertEqual(self.interface.reassemblers[new_key], mock_reassembler)
class MockDiscoveredPeer:
"""Mock discovered peer for testing."""
def __init__(self, address: str, name: str = "RNS-test", rssi: int = -50):
self.address = address
self.name = name
self.rssi = rssi
class MockBLEInterfaceMACRotation:
"""
Test harness for MAC rotation handling in _select_peers_to_connect.
Tests the fix where after MAC rotation cleanup, peer is immediately added
to scored_peers list bypassing MAC sorting.
"""
def __init__(self):
self.identity_to_address = {}
self.address_to_identity = {}
self.spawned_interfaces = {}
self.peers = {} # address -> active connection
self.connection_attempt_times = {}
self.local_mac = "AA:BB:CC:DD:EE:FF" # Higher MAC for sorting test
self.cleanup_called_with = [] # Track cleanup calls
def _compute_identity_hash(self, peer_identity):
"""Compute identity hash."""
if peer_identity:
return peer_identity.hex()[:16]
return None
def _score_peer(self, peer):
"""Score a peer for connection prioritization."""
return abs(peer.rssi) # Lower RSSI = higher priority
def _cleanup_stale_interface(self, identity_hash: str, old_address: str):
"""Mock cleanup - just track that it was called."""
self.cleanup_called_with.append((identity_hash, old_address))
# Clean up mappings like real method
if identity_hash in self.spawned_interfaces:
del self.spawned_interfaces[identity_hash]
if identity_hash in self.identity_to_address:
del self.identity_to_address[identity_hash]
def _select_peers_to_connect(self, discovered_peers):
"""
Simplified version of _select_peers_to_connect that includes the MAC rotation fix.
Returns list of (score, peer) tuples.
"""
scored_peers = []
for peer in discovered_peers:
address = peer.address
# Check for MAC rotation
peer_identity = self.address_to_identity.get(address)
if peer_identity:
identity_hash = self._compute_identity_hash(peer_identity)
if identity_hash in self.spawned_interfaces:
existing_address = self.identity_to_address.get(identity_hash)
if existing_address and existing_address != address:
# Same identity at different MAC = MAC rotation
if existing_address in self.peers:
# Old connection still active - skip
continue
else:
# Old connection dead - clean up and allow new connection
self._cleanup_stale_interface(identity_hash, existing_address)
# FIX: Bypass MAC sorting - we must reconnect after MAC rotation
score = self._score_peer(peer)
scored_peers.append((score, peer))
continue # Skip remaining checks, peer already added
# MAC sorting check (only reached if NOT MAC rotation)
if self.local_mac > address:
# We have higher MAC, we should initiate - add peer
score = self._score_peer(peer)
scored_peers.append((score, peer))
# else: peer has higher MAC, they should initiate - skip
return scored_peers
class TestMACRotationFix(unittest.TestCase):
"""
Test MAC rotation handling in _select_peers_to_connect.
Bug: After MAC rotation, peer interface wasn't recreated because MAC sorting
check skipped the peer. Fix: After cleanup, immediately add peer and continue.
"""
def setUp(self):
"""Set up test fixtures."""
self.interface = MockBLEInterfaceMACRotation()
def test_mac_rotation_detected_and_cleanup_called(self):
"""
Test that MAC rotation is detected and cleanup is called.
When same identity appears at new MAC with stale old connection,
_cleanup_stale_interface should be called.
"""
old_address = "11:22:33:44:55:66"
new_address = "77:88:99:AA:BB:CC"
identity_hash = "ab5609dfffb33b21"
peer_identity = bytes.fromhex("ab5609dfffb33b21a102e1ff81196be5")
# Set up: identity exists at old address, but connection is stale (not in self.peers)
self.interface.identity_to_address[identity_hash] = old_address
self.interface.address_to_identity[new_address] = peer_identity
self.interface.spawned_interfaces[identity_hash] = Mock()
# Note: old_address NOT in self.peers (connection is dead)
# Create peer at new address
peer = MockDiscoveredPeer(new_address, "RNS-ab5609")
# Act
self.interface._select_peers_to_connect([peer])
# Assert: cleanup was called with correct arguments
self.assertEqual(len(self.interface.cleanup_called_with), 1)
self.assertEqual(self.interface.cleanup_called_with[0], (identity_hash, old_address))
def test_mac_rotation_bypasses_mac_sorting(self):
"""
Test that MAC rotation bypasses MAC sorting.
After MAC rotation cleanup, peer should be added to connection list
EVEN IF local MAC > peer MAC (which would normally skip the peer).
"""
old_address = "11:22:33:44:55:66"
new_address = "00:11:22:33:44:55" # Lower than local MAC (AA:BB:CC...)
identity_hash = "ab5609dfffb33b21"
peer_identity = bytes.fromhex("ab5609dfffb33b21a102e1ff81196be5")
# Set up: identity exists at old address, but connection is stale
self.interface.identity_to_address[identity_hash] = old_address
self.interface.address_to_identity[new_address] = peer_identity
self.interface.spawned_interfaces[identity_hash] = Mock()
# old_address NOT in self.peers (stale connection)
# Create peer at new address (lower MAC than local)
peer = MockDiscoveredPeer(new_address, "RNS-ab5609")
# Act
result = self.interface._select_peers_to_connect([peer])
# Assert: peer was added despite having lower MAC
self.assertEqual(len(result), 1)
self.assertEqual(result[0][1].address, new_address)
def test_mac_sorting_applies_when_no_mac_rotation(self):
"""
Test that normal MAC sorting still works when there's no MAC rotation.
When peer MAC > local MAC and no rotation, peer should be skipped
(peer should initiate connection since they have higher MAC).
"""
new_address = "FF:FF:FF:FF:FF:FF" # Higher than local MAC (AA:BB:CC...)
# No existing identity mapping - this is a new peer
peer = MockDiscoveredPeer(new_address, "RNS-newpeer")
# Act
result = self.interface._select_peers_to_connect([peer])
# Assert: peer was NOT added (they have higher MAC, they should initiate)
self.assertEqual(len(result), 0)
def test_mac_sorting_adds_peer_when_local_mac_higher(self):
"""
Test that MAC sorting adds peer when local MAC is higher.
When local MAC > peer MAC and no rotation, we should initiate.
"""
new_address = "00:11:22:33:44:55" # Lower than local MAC (AA:BB:CC...)
# Set local MAC higher
self.interface.local_mac = "FF:FF:FF:FF:FF:FF"
peer = MockDiscoveredPeer(new_address, "RNS-newpeer")
# Act
result = self.interface._select_peers_to_connect([peer])
# Assert: peer was added (we have higher MAC, we initiate)
self.assertEqual(len(result), 1)
def test_active_connection_skips_rotation(self):
"""
Test that active connection prevents MAC rotation cleanup.
When old connection is still active (in self.peers), we should
NOT clean up or add the new address.
"""
old_address = "11:22:33:44:55:66"
new_address = "77:88:99:AA:BB:CC"
identity_hash = "ab5609dfffb33b21"
peer_identity = bytes.fromhex("ab5609dfffb33b21a102e1ff81196be5")
# Set up: identity exists at old address AND connection is active
self.interface.identity_to_address[identity_hash] = old_address
self.interface.address_to_identity[new_address] = peer_identity
self.interface.spawned_interfaces[identity_hash] = Mock()
self.interface.peers[old_address] = Mock() # Connection is ACTIVE
peer = MockDiscoveredPeer(new_address, "RNS-ab5609")
# Act
result = self.interface._select_peers_to_connect([peer])
# Assert: cleanup was NOT called, peer was NOT added
self.assertEqual(len(self.interface.cleanup_called_with), 0)
self.assertEqual(len(result), 0)
class TestEnsureAdvertisingRealClass(unittest.TestCase):
"""
Test ensure_advertising() method on the REAL AndroidBLEDriver class.
This tests the actual code in android_ble_driver.py to get coverage.
Android may silently stop BLE advertising when:
- App goes to background
- Screen turns off
- Device enters Doze mode
This method checks and restarts advertising if needed.
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class with proper mock setup."""
# Remove existing bluetooth_driver mock to replace with proper class mock
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
# Create proper base class (not MagicMock)
class MockBLEDriverInterface:
"""Mock base class for AndroidBLEDriver."""
pass
# Set up bluetooth_driver with proper class
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
# Add ble_modules to path
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
# Import the real class and module
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def setUp(self):
"""Set up test fixtures with a real driver instance."""
# Create driver instance without calling __init__
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver.kotlin_bridge = None
# Set up log capture in the module's namespace
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_ensure_advertising_no_bridge_returns_false(self):
"""
Test that ensure_advertising returns False when no bridge.
Without a Kotlin bridge, we cannot check or restart advertising.
"""
self.driver.kotlin_bridge = None
result = self.driver.ensure_advertising()
self.assertFalse(result)
self.assertTrue(len(self.log_calls) > 0, "Expected log calls")
self.assertTrue(any("no bridge" in str(msg).lower() for msg, _ in self.log_calls))
def test_ensure_advertising_already_active_returns_true(self):
"""
Test that ensure_advertising returns True when advertising is active.
When Kotlin reports advertising is active, return True without restart.
"""
mock_bridge = Mock()
mock_bridge.ensureAdvertising.return_value = True
self.driver.kotlin_bridge = mock_bridge
result = self.driver.ensure_advertising()
self.assertTrue(result)
mock_bridge.ensureAdvertising.assert_called_once()
def test_ensure_advertising_was_stopped_returns_false_and_logs(self):
"""
Test that ensure_advertising returns False and logs when restart triggered.
When Kotlin reports advertising was stopped and restart was triggered,
return False and log an info message.
"""
mock_bridge = Mock()
mock_bridge.ensureAdvertising.return_value = False
self.driver.kotlin_bridge = mock_bridge
result = self.driver.ensure_advertising()
self.assertFalse(result)
mock_bridge.ensureAdvertising.assert_called_once()
self.assertTrue(len(self.log_calls) > 0, "Expected log calls")
self.assertTrue(any("restarting" in str(msg).lower() for msg, _ in self.log_calls))
def test_ensure_advertising_exception_returns_false(self):
"""
Test that ensure_advertising returns False on exception.
If Kotlin bridge throws an exception, catch it, log error, return False.
"""
mock_bridge = Mock()
mock_bridge.ensureAdvertising.side_effect = RuntimeError("Bridge error")
self.driver.kotlin_bridge = mock_bridge
result = self.driver.ensure_advertising()
self.assertFalse(result)
self.assertTrue(len(self.log_calls) > 0, "Expected log calls")
self.assertTrue(any("error" in str(msg).lower() for msg, _ in self.log_calls))
class TestRequestIdentityResyncRealClass(unittest.TestCase):
"""
Test request_identity_resync() method on the REAL AndroidBLEDriver class.
This tests the actual code in android_ble_driver.py to get coverage.
The method is called when BLEInterface receives data from a peer but has
no identity mapping (Python's disconnect callback fired but Kotlin
maintained the GATT connection).
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class with proper mock setup."""
# Remove existing bluetooth_driver mock to replace with proper class mock
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
# Create proper base class (not MagicMock)
class MockBLEDriverInterface:
"""Mock base class for AndroidBLEDriver."""
pass
# Set up bluetooth_driver with proper class
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
# Add ble_modules to path
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
# Import the real class and module
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def setUp(self):
"""Set up test fixtures with a real driver instance."""
# Create driver instance without calling __init__
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver.kotlin_bridge = None
# Set up log capture in the module's namespace
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_request_identity_resync_no_bridge_returns_false(self):
"""
Test that request_identity_resync returns False when no bridge.
Without a Kotlin bridge, we cannot request identity resync.
"""
self.driver.kotlin_bridge = None
result = self.driver.request_identity_resync("AA:BB:CC:DD:EE:FF")
self.assertFalse(result)
self.assertTrue(len(self.log_calls) > 0, "Expected log calls")
self.assertTrue(any("no bridge" in str(msg).lower() for msg, _ in self.log_calls))
def test_request_identity_resync_found_returns_true(self):
"""
Test that request_identity_resync returns True when identity found.
When Kotlin finds the identity for the address, return True.
"""
mock_bridge = Mock()
mock_bridge.requestIdentityResync.return_value = True
self.driver.kotlin_bridge = mock_bridge
result = self.driver.request_identity_resync("AA:BB:CC:DD:EE:FF")
self.assertTrue(result)
mock_bridge.requestIdentityResync.assert_called_once_with("AA:BB:CC:DD:EE:FF")
def test_request_identity_resync_not_found_returns_false(self):
"""
Test that request_identity_resync returns False when identity not found.
When Kotlin doesn't find the identity for the address, return False.
"""
mock_bridge = Mock()
mock_bridge.requestIdentityResync.return_value = False
self.driver.kotlin_bridge = mock_bridge
result = self.driver.request_identity_resync("AA:BB:CC:DD:EE:FF")
self.assertFalse(result)
mock_bridge.requestIdentityResync.assert_called_once()
def test_request_identity_resync_exception_returns_false(self):
"""
Test that request_identity_resync returns False on exception.
If Kotlin bridge throws an exception, catch it, log error, return False.
"""
mock_bridge = Mock()
mock_bridge.requestIdentityResync.side_effect = RuntimeError("Bridge error")
self.driver.kotlin_bridge = mock_bridge
result = self.driver.request_identity_resync("AA:BB:CC:DD:EE:FF")
self.assertFalse(result)
self.assertTrue(len(self.log_calls) > 0, "Expected log calls")
self.assertTrue(any("error" in str(msg).lower() for msg, _ in self.log_calls))
class TestDuplicateIdentityCallbackMissing(unittest.TestCase):
"""
BUG TEST: on_duplicate_identity_detected callback is NOT wired up on Android.
BUG DESCRIPTION:
----------------
On Linux, BLEInterface sets driver.on_duplicate_identity_detected = _check_duplicate_identity
and the LinuxBluetoothDriver calls this callback when identity is received.
On Android:
1. BLEInterface sets driver.on_duplicate_identity_detected = _check_duplicate_identity
2. AndroidBLEDriver._setup_kotlin_callbacks() does NOT wire this to Kotlin
3. KotlinBLEBridge doesn't have onDuplicateIdentityDetected callback anyway
4. Result: Python's duplicate identity check is NEVER called on Android
This means duplicate connections (same identity at different MAC addresses)
are allowed on Android, wasting resources and potentially causing duplicate
packet delivery.
EXPECTED BEHAVIOR:
-----------------
AndroidBLEDriver._setup_kotlin_callbacks() should set up:
self.kotlin_bridge.setOnDuplicateIdentityDetected(self._handle_duplicate_identity)
And KotlinBLEBridge should call this callback in handleIdentityReceived()
BEFORE notifying Python of the connection.
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class with proper mock setup."""
# Remove existing bluetooth_driver mock to replace with proper class mock
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
# Create proper base class (not MagicMock)
class MockBLEDriverInterface:
"""Mock base class for AndroidBLEDriver."""
pass
# Set up bluetooth_driver with proper class
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
# Add ble_modules to path
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
# Import the real class and module
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def test_setup_kotlin_callbacks_wires_duplicate_identity_detection(self):
"""
Test that _setup_kotlin_callbacks wires up on_duplicate_identity_detected.
This verifies that:
1. on_duplicate_identity_detected callback is set on the driver
2. _setup_kotlin_callbacks() wires it to Kotlin via setOnDuplicateIdentityDetected
3. Therefore, Python's duplicate check IS called on Android
"""
# Create driver instance without calling __init__
driver = object.__new__(self.AndroidBLEDriver)
driver._connected_peers = []
driver._peer_roles = {}
driver._peer_mtus = {}
driver._pending_identities = {}
driver._identity_lock = threading.Lock()
driver._address_to_identity = {}
driver._identity_to_address = {}
# Mock the Kotlin bridge
mock_bridge = MagicMock()
driver.kotlin_bridge = mock_bridge
# Set up the on_duplicate_identity_detected callback (simulating what BLEInterface does)
def mock_check_duplicate(address, identity):
return False # Not a duplicate
driver.on_duplicate_identity_detected = mock_check_duplicate
# Suppress RNS.log calls
with patch.object(self.abd_module, 'RNS') as mock_rns:
mock_rns.LOG_DEBUG = 5
mock_rns.LOG_INFO = 4
mock_rns.log = MagicMock()
# Call the actual method
driver._setup_kotlin_callbacks()
# Verify that setOnDuplicateIdentityDetected WAS called
mock_bridge.setOnDuplicateIdentityDetected.assert_called_once()
# The callback should be a lambda that calls _handle_duplicate_identity_detected
callback = mock_bridge.setOnDuplicateIdentityDetected.call_args[0][0]
self.assertIsNotNone(callback, "Callback should be set")
def test_all_callbacks_wired_including_duplicate_identity_detection(self):
"""
Test that all callbacks are wired up in _setup_kotlin_callbacks,
including the duplicate identity detection callback.
This verifies that the full callback chain is established for Android.
"""
# List of ALL callbacks that should be wired up in _setup_kotlin_callbacks
wired_callbacks = [
"setOnDeviceDiscovered",
"setOnConnected",
"setOnDisconnected",
"setOnDataReceived",
"setOnIdentityReceived",
"setOnMtuNegotiated",
"setOnAddressChanged",
"setOnDuplicateIdentityDetected", # Added for MAC rotation handling
]
# Verify the list is complete
self.assertEqual(len(wired_callbacks), 8, "Should have 8 wired callbacks")
self.assertIn(
"setOnDuplicateIdentityDetected",
wired_callbacks,
"setOnDuplicateIdentityDetected must be in the list of wired callbacks "
"for duplicate identity detection to work on Android."
)
class TestDuplicateIdentityDetectionFlow(unittest.TestCase):
"""
Test the expected flow for duplicate identity detection on Android.
Documents how the fix should work:
1. Kotlin receives identity in handleIdentityReceived()
2. Kotlin calls Python's on_duplicate_identity_detected(address, identity)
3. Python's _check_duplicate_identity checks identity_to_address map
4. If duplicate found, Python returns True
5. Kotlin rejects the connection with safe message format (no blacklist trigger)
"""
def test_duplicate_identity_detection_expected_flow(self):
"""
Document the expected flow for duplicate identity detection.
This test describes what SHOULD happen after the fix is implemented.
"""
# Expected flow:
flow_steps = [
"1. Identity X already connected at MAC_OLD (identity_to_address[hash(X)] = MAC_OLD)",
"2. MAC_NEW connects (Android MAC rotation)",
"3. Kotlin receives identity X from MAC_NEW in handleIdentityReceived()",
"4. BEFORE calling onConnected, Kotlin calls onDuplicateIdentityDetected(MAC_NEW, X)",
"5. Python's _check_duplicate_identity finds hash(X) -> MAC_OLD",
"6. Python returns True (is duplicate)",
"7. Kotlin logs safe message: 'Duplicate identity rejected for MAC_NEW' (no blacklist)",
"8. Kotlin disconnects MAC_NEW GATT connection",
"9. Connection from MAC_NEW is rejected, MAC_OLD connection continues",
]
# The key insight: step 4 doesn't exist today (BUG)
# After the fix, step 4 will call onDuplicateIdentityDetected
# Verify we have all expected steps documented
self.assertEqual(len(flow_steps), 9)
self.assertIn("onDuplicateIdentityDetected", flow_steps[3])
def test_safe_error_message_formats_for_duplicate_rejection(self):
"""
Test that safe error message formats don't trigger blacklist.
When duplicate identity is rejected, the error message must NOT match
the blacklist regex pattern "Connection failed to" or "Connection timeout to".
"""
import re
mac = "AA:BB:CC:DD:EE:02"
# Blacklist regex from BLEInterface._error_callback
blacklist_regex = r'(?:Connection (?:failed|timeout) to|to) ([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})'
# Messages that SHOULD trigger blacklist (real failures)
unsafe_messages = [
f"Connection failed to {mac}: timeout",
f"Connection timeout to {mac}",
]
# Messages that SHOULD NOT trigger blacklist (duplicate identity)
safe_messages = [
f"Duplicate identity rejected for {mac}",
f"Rejecting duplicate identity from {mac}",
f"MAC rotation duplicate detected: {mac}",
]
# Verify unsafe messages match
for msg in unsafe_messages:
self.assertIsNotNone(
re.search(blacklist_regex, msg),
f"Unsafe message should match blacklist regex: {msg}"
)
# Verify safe messages do NOT match
for msg in safe_messages:
self.assertIsNone(
re.search(blacklist_regex, msg),
f"Safe message should NOT match blacklist regex: {msg}"
)
class TestEnsureBytesFunction(unittest.TestCase):
"""
Test the ensure_bytes() utility function.
This function converts Chaquopy jarray to Python bytes when needed.
When Kotlin passes ByteArray to Python via Chaquopy, it arrives as a
jarray('B') (Java array), not Python bytes.
"""
@classmethod
def setUpClass(cls):
"""Import the real ensure_bytes function."""
# Remove existing bluetooth_driver mock to replace with proper class mock
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
# Create proper base class (not MagicMock)
class MockBLEDriverInterface:
"""Mock base class for AndroidBLEDriver."""
pass
# Set up bluetooth_driver with proper class
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
# Add ble_modules to path
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
# Import the real function
import android_ble_driver as abd_module
cls.abd_module = abd_module
def test_ensure_bytes_with_bytes_returns_same_object(self):
"""
Test that ensure_bytes returns the same object when given bytes.
When input is already Python bytes, no conversion is needed.
"""
data = b'\x01\x02\x03\x04\x05'
result = self.abd_module.ensure_bytes(data)
self.assertIs(result, data) # Same object
self.assertEqual(result, b'\x01\x02\x03\x04\x05')
def test_ensure_bytes_with_list_converts_to_bytes(self):
"""
Test that ensure_bytes converts iterables to bytes.
jarray is iterable, so we can test with a list which behaves similarly.
"""
# Simulate jarray-like behavior with a list of integers
data = [0x01, 0x02, 0x03, 0x04, 0x05]
result = self.abd_module.ensure_bytes(data)
self.assertIsInstance(result, bytes)
self.assertEqual(result, b'\x01\x02\x03\x04\x05')
def test_ensure_bytes_with_empty_bytes(self):
"""
Test that ensure_bytes handles empty bytes correctly.
"""
data = b''
result = self.abd_module.ensure_bytes(data)
self.assertIs(result, data)
self.assertEqual(result, b'')
def test_ensure_bytes_with_empty_iterable(self):
"""
Test that ensure_bytes handles empty iterables correctly.
"""
data = []
result = self.abd_module.ensure_bytes(data)
self.assertIsInstance(result, bytes)
self.assertEqual(result, b'')
def test_ensure_bytes_with_tuple(self):
"""
Test that ensure_bytes converts tuple (another iterable) to bytes.
"""
data = (0x41, 0x42, 0x43) # 'ABC'
result = self.abd_module.ensure_bytes(data)
self.assertIsInstance(result, bytes)
self.assertEqual(result, b'ABC')
class MockJarray:
"""
Mock Chaquopy jarray for testing ensure_bytes.
Chaquopy's jarray is iterable but not a bytes instance.
This mock simulates that behavior.
"""
def __init__(self, data):
self._data = list(data)
def __iter__(self):
return iter(self._data)
class TestEnsureBytesWithMockJarray(unittest.TestCase):
"""
Test ensure_bytes with a mock jarray that mimics Chaquopy behavior.
"""
@classmethod
def setUpClass(cls):
"""Import the real ensure_bytes function."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.abd_module = abd_module
def test_ensure_bytes_with_mock_jarray(self):
"""
Test that ensure_bytes converts mock jarray to bytes.
This simulates the actual Chaquopy jarray behavior where data
arrives as a Java array, not Python bytes.
"""
# Create mock jarray with identity data
jarray_data = MockJarray([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10])
result = self.abd_module.ensure_bytes(jarray_data)
self.assertIsInstance(result, bytes)
self.assertEqual(len(result), 16)
self.assertEqual(result.hex(), '0102030405060708090a0b0c0d0e0f10')
def test_ensure_bytes_jarray_allows_hex_method(self):
"""
Test that after conversion, we can call bytes methods like .hex().
This is the main purpose of ensure_bytes - to allow Python bytes
methods on data received from Kotlin.
"""
jarray_data = MockJarray([0xab, 0xcd, 0xef])
result = self.abd_module.ensure_bytes(jarray_data)
# These would fail on jarray but work after conversion
self.assertEqual(result.hex(), 'abcdef')
self.assertEqual(len(result), 3)
self.assertEqual(result[0], 0xab)
class TestHandleDuplicateIdentityDetected(unittest.TestCase):
"""
Test _handle_duplicate_identity_detected method on the REAL AndroidBLEDriver class.
This method is called by Kotlin before accepting a connection to check
if the identity is already connected at a different address (MAC rotation).
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class with proper mock setup."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def setUp(self):
"""Set up test fixtures with a real driver instance."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver.kotlin_bridge = None
self.driver.on_duplicate_identity_detected = None
# Set up log capture
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_duplicate_identity_no_callback_returns_false(self):
"""
Test that _handle_duplicate_identity_detected returns False when no callback.
When BLEInterface hasn't set on_duplicate_identity_detected, we allow
all connections (fail open).
"""
address = "AA:BB:CC:DD:EE:FF"
identity = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
# No callback set
self.driver.on_duplicate_identity_detected = None
result = self.driver._handle_duplicate_identity_detected(address, identity)
self.assertFalse(result)
def test_duplicate_identity_callback_returns_false_for_non_duplicate(self):
"""
Test that _handle_duplicate_identity_detected returns False for non-duplicates.
When the callback returns False (not a duplicate), the connection is allowed.
"""
address = "AA:BB:CC:DD:EE:FF"
identity = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
# Callback returns False (not a duplicate)
self.driver.on_duplicate_identity_detected = Mock(return_value=False)
result = self.driver._handle_duplicate_identity_detected(address, identity)
self.assertFalse(result)
self.driver.on_duplicate_identity_detected.assert_called_once_with(address, identity)
def test_duplicate_identity_callback_returns_true_for_duplicate(self):
"""
Test that _handle_duplicate_identity_detected returns True for duplicates.
When the callback returns True (is duplicate), the connection is rejected.
"""
address = "AA:BB:CC:DD:EE:FF"
identity = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
# Callback returns True (is duplicate)
self.driver.on_duplicate_identity_detected = Mock(return_value=True)
result = self.driver._handle_duplicate_identity_detected(address, identity)
self.assertTrue(result)
self.driver.on_duplicate_identity_detected.assert_called_once_with(address, identity)
# Should log warning about duplicate rejection
self.assertTrue(any("Duplicate identity rejected" in str(msg) for msg, _ in self.log_calls))
def test_duplicate_identity_exception_returns_false(self):
"""
Test that _handle_duplicate_identity_detected returns False on exception.
On error, we fail open and allow the connection.
"""
address = "AA:BB:CC:DD:EE:FF"
identity = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
# Callback raises exception
self.driver.on_duplicate_identity_detected = Mock(side_effect=RuntimeError("Test error"))
result = self.driver._handle_duplicate_identity_detected(address, identity)
self.assertFalse(result)
# Should log error
self.assertTrue(any("Error in duplicate identity check" in str(msg) for msg, _ in self.log_calls))
def test_duplicate_identity_converts_jarray_to_bytes(self):
"""
Test that _handle_duplicate_identity_detected converts jarray to bytes.
When Kotlin passes ByteArray, it arrives as jarray which needs conversion.
"""
address = "AA:BB:CC:DD:EE:FF"
# Use mock jarray instead of bytes
jarray_identity = MockJarray([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10])
# Callback that checks the type
received_identity = []
def capture_callback(addr, identity):
received_identity.append(identity)
return False
self.driver.on_duplicate_identity_detected = capture_callback
self.driver._handle_duplicate_identity_detected(address, jarray_identity)
# Verify callback received bytes, not jarray
self.assertEqual(len(received_identity), 1)
self.assertIsInstance(received_identity[0], bytes)
self.assertEqual(received_identity[0].hex(), '0102030405060708090a0b0c0d0e0f10')
class TestHandleDataReceivedEnsureBytes(unittest.TestCase):
"""
Test that _handle_data_received uses ensure_bytes to convert jarray.
This tests the specific code path where data arrives from Kotlin as
jarray and needs conversion before processing.
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def setUp(self):
"""Set up test fixtures with a real driver instance."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver._connected_peers = []
self.driver._peer_roles = {}
self.driver._peer_mtus = {}
self.driver._pending_identities = {}
self.driver._identity_lock = threading.Lock()
self.driver.on_data_received = None
self.driver.on_device_connected = None
self.driver.on_mtu_negotiated = None
# Set up log capture
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_handle_data_received_converts_jarray(self):
"""
Test that _handle_data_received converts jarray data to bytes.
The on_data_received callback should receive Python bytes, not jarray.
"""
address = "AA:BB:CC:DD:EE:FF"
self.driver._connected_peers.append(address)
# Simulate jarray data from Kotlin
jarray_data = MockJarray([0x48, 0x65, 0x6c, 0x6c, 0x6f]) # "Hello"
received_data = []
def capture_callback(addr, data):
received_data.append((addr, data))
self.driver.on_data_received = capture_callback
self.driver._handle_data_received(address, jarray_data)
# Verify callback received bytes
self.assertEqual(len(received_data), 1)
addr, data = received_data[0]
self.assertEqual(addr, address)
self.assertIsInstance(data, bytes)
self.assertEqual(data, b'Hello')
def test_handle_data_received_passes_through_bytes(self):
"""
Test that _handle_data_received passes through bytes unchanged.
"""
address = "AA:BB:CC:DD:EE:FF"
self.driver._connected_peers.append(address)
bytes_data = b'Test data'
received_data = []
self.driver.on_data_received = lambda addr, data: received_data.append((addr, data))
self.driver._handle_data_received(address, bytes_data)
self.assertEqual(len(received_data), 1)
self.assertEqual(received_data[0][1], b'Test data')
def test_handle_data_received_finalizes_pending_connection(self):
"""
Test race condition: data arrives for address with pending identity but not connected.
This tests the fix where data arrives BEFORE onConnected callback fires,
but AFTER onIdentityReceived cached the identity. The driver should:
1. Detect pending identity for this address
2. Add to connected_peers
3. Call on_device_connected with the pending identity
4. Call on_mtu_negotiated (to create reassembler)
5. Then pass data to on_data_received
"""
address = "AA:BB:CC:DD:EE:FF"
pending_identity = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
data = b"Hello"
# Address NOT in connected_peers, but HAS pending identity
self.driver._connected_peers = []
self.driver._pending_identities[address] = pending_identity
self.driver._peer_mtus[address] = 512 # Cached MTU from earlier
connected_calls = []
mtu_calls = []
data_calls = []
self.driver.on_device_connected = lambda addr, identity: connected_calls.append((addr, identity))
self.driver.on_mtu_negotiated = lambda addr, mtu: mtu_calls.append((addr, mtu))
self.driver.on_data_received = lambda addr, data: data_calls.append((addr, data))
self.driver._handle_data_received(address, data)
# Verify connection was finalized
self.assertIn(address, self.driver._connected_peers)
self.assertEqual(self.driver._peer_roles[address], "peripheral")
# Verify pending identity was consumed
self.assertNotIn(address, self.driver._pending_identities)
# Verify callbacks called in correct order
self.assertEqual(len(connected_calls), 1)
self.assertEqual(connected_calls[0][0], address)
self.assertEqual(connected_calls[0][1], pending_identity)
self.assertEqual(len(mtu_calls), 1)
self.assertEqual(mtu_calls[0], (address, 512))
self.assertEqual(len(data_calls), 1)
self.assertEqual(data_calls[0], (address, data))
def test_handle_data_received_uses_default_mtu_when_not_cached(self):
"""
Test that default MTU (23) is used when no cached MTU available.
When data arrives before connection AND MTU wasn't negotiated,
use BLE 4.0 minimum MTU (23).
"""
address = "AA:BB:CC:DD:EE:FF"
pending_identity = b'\x01' * 16
data = b"Hello"
# Pending identity but NO cached MTU
self.driver._connected_peers = []
self.driver._pending_identities[address] = pending_identity
# No MTU in _peer_mtus
mtu_calls = []
self.driver.on_device_connected = Mock()
self.driver.on_mtu_negotiated = lambda addr, mtu: mtu_calls.append((addr, mtu))
self.driver.on_data_received = Mock()
self.driver._handle_data_received(address, data)
# Verify default MTU (23) was used
self.assertEqual(len(mtu_calls), 1)
self.assertEqual(mtu_calls[0], (address, 23))
class TestAndroidBLEDriverLifecycle(unittest.TestCase):
"""
Test lifecycle methods on the REAL AndroidBLEDriver class.
Tests __init__, start(), and stop() to ensure proper state transitions.
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class with proper mock setup."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
cls.DriverState = MockDriverState
def setUp(self):
"""Set up test fixtures."""
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_init_creates_proper_state(self):
"""Test that __init__ initializes all required attributes."""
driver = object.__new__(self.AndroidBLEDriver)
driver.__init__()
self.assertEqual(driver._state, self.DriverState.IDLE)
self.assertIsNone(driver.kotlin_bridge)
self.assertIsNone(driver._transport_identity)
self.assertEqual(driver._connected_peers, [])
self.assertEqual(driver._peer_roles, {})
self.assertEqual(driver._peer_mtus, {})
self.assertEqual(driver._address_to_identity, {})
self.assertEqual(driver._identity_to_address, {})
self.assertEqual(driver._pending_identities, {})
self.assertIsInstance(driver._identity_lock, type(threading.Lock()))
def test_init_accepts_kwargs(self):
"""Test that __init__ accepts configuration kwargs."""
driver = object.__new__(self.AndroidBLEDriver)
driver.__init__(service_discovery_delay=1.5)
self.assertEqual(driver._service_discovery_delay, 1.5)
def test_start_with_no_bridge_raises(self):
"""Test that start() raises exception when bridge unavailable."""
driver = object.__new__(self.AndroidBLEDriver)
driver.__init__()
driver.on_error = Mock()
# Mock _get_kotlin_bridge to return None
driver._get_kotlin_bridge = Mock(return_value=None)
with self.assertRaises(Exception) as context:
driver.start("service-uuid", "rx-uuid", "tx-uuid", "id-uuid")
self.assertIn("Failed to get KotlinBLEBridge", str(context.exception))
driver.on_error.assert_called_once()
def test_start_when_already_started_logs_warning(self):
"""Test that start() logs warning when not in IDLE state."""
driver = object.__new__(self.AndroidBLEDriver)
driver.__init__()
driver._state = self.DriverState.SCANNING
driver.start("service-uuid", "rx-uuid", "tx-uuid", "id-uuid")
self.assertTrue(any("Cannot start" in str(msg) for msg, _ in self.log_calls))
def test_start_success_configures_bridge(self):
"""Test that start() configures bridge and sets up callbacks."""
driver = object.__new__(self.AndroidBLEDriver)
driver.__init__()
mock_bridge = MagicMock()
driver._get_kotlin_bridge = Mock(return_value=mock_bridge)
driver._transport_identity = b'\x01' * 16
driver.start("service-uuid", "rx-uuid", "tx-uuid", "id-uuid")
# Verify UUIDs stored
self.assertEqual(driver._service_uuid, "service-uuid")
self.assertEqual(driver._rx_char_uuid, "rx-uuid")
self.assertEqual(driver._tx_char_uuid, "tx-uuid")
self.assertEqual(driver._identity_char_uuid, "id-uuid")
# Verify bridge methods called
mock_bridge.startAsync.assert_called_once_with(
"service-uuid", "rx-uuid", "tx-uuid", "id-uuid"
)
mock_bridge.setIdentity.assert_called_once_with(b'\x01' * 16)
def test_stop_when_idle_does_nothing(self):
"""Test that stop() returns early when already IDLE."""
driver = object.__new__(self.AndroidBLEDriver)
driver.__init__()
driver._state = self.DriverState.IDLE
initial_log_count = len(self.log_calls)
driver.stop()
# Should not log "Stopping..." since we're already idle
stop_logs = [msg for msg, _ in self.log_calls[initial_log_count:] if "Stopping" in str(msg)]
self.assertEqual(len(stop_logs), 0)
def test_stop_clears_state_and_calls_bridge(self):
"""Test that stop() clears state and stops bridge."""
driver = object.__new__(self.AndroidBLEDriver)
driver.__init__()
driver._state = self.DriverState.SCANNING
mock_bridge = MagicMock()
driver.kotlin_bridge = mock_bridge
driver._connected_peers = ["AA:BB:CC:DD:EE:FF"]
driver._peer_roles = {"AA:BB:CC:DD:EE:FF": "central"}
driver.stop()
mock_bridge.stopAsync.assert_called_once()
self.assertEqual(driver._connected_peers, [])
self.assertEqual(driver._peer_roles, {})
self.assertEqual(driver._state, self.DriverState.IDLE)
def test_stop_handles_exception(self):
"""Test that stop() handles exceptions gracefully."""
driver = object.__new__(self.AndroidBLEDriver)
driver.__init__()
driver._state = self.DriverState.SCANNING
driver.on_error = Mock()
mock_bridge = MagicMock()
mock_bridge.stopAsync.side_effect = RuntimeError("Bridge error")
driver.kotlin_bridge = mock_bridge
# Should not raise
driver.stop()
driver.on_error.assert_called_once()
self.assertTrue(any("Error stopping" in str(msg) for msg, _ in self.log_calls))
class TestAndroidBLEDriverConfiguration(unittest.TestCase):
"""
Test configuration methods on the REAL AndroidBLEDriver class.
Tests set_identity(), set_service_discovery_delay(), set_power_mode().
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def setUp(self):
"""Set up test fixtures."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver._transport_identity = None
self.driver.kotlin_bridge = None
self.driver._service_discovery_delay = 0.5
self.driver._power_mode = "balanced"
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_set_identity_valid_16_bytes(self):
"""Test that set_identity() accepts valid 16-byte identity."""
identity = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
self.driver.set_identity(identity)
self.assertEqual(self.driver._transport_identity, identity)
def test_set_identity_invalid_length_raises(self):
"""Test that set_identity() raises ValueError for invalid length."""
with self.assertRaises(ValueError) as context:
self.driver.set_identity(b'\x01\x02\x03')
self.assertIn("16 bytes", str(context.exception))
def test_set_identity_propagates_to_bridge(self):
"""Test that set_identity() propagates to Kotlin bridge if available."""
identity = b'\x01' * 16
mock_bridge = MagicMock()
self.driver.kotlin_bridge = mock_bridge
self.driver.set_identity(identity)
mock_bridge.setIdentity.assert_called_once_with(identity)
def test_set_service_discovery_delay(self):
"""Test that set_service_discovery_delay() stores value."""
self.driver.set_service_discovery_delay(2.5)
self.assertEqual(self.driver._service_discovery_delay, 2.5)
self.assertTrue(any("discovery delay" in str(msg).lower() for msg, _ in self.log_calls))
def test_set_power_mode_valid_modes(self):
"""Test that set_power_mode() accepts valid modes."""
for mode in ["aggressive", "balanced", "saver"]:
self.driver.set_power_mode(mode)
self.assertEqual(self.driver._power_mode, mode)
def test_set_power_mode_invalid_mode_raises(self):
"""Test that set_power_mode() raises ValueError for invalid mode."""
with self.assertRaises(ValueError) as context:
self.driver.set_power_mode("turbo")
self.assertIn("Invalid power mode", str(context.exception))
class TestAndroidBLEDriverScanning(unittest.TestCase):
"""
Test scanning methods on the REAL AndroidBLEDriver class.
Tests start_scanning() and stop_scanning().
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
cls.DriverState = MockDriverState
def setUp(self):
"""Set up test fixtures."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver.kotlin_bridge = None
self.driver._state = MockDriverState.IDLE
self.driver.on_error = None
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_start_scanning_no_bridge_raises(self):
"""Test that start_scanning() raises when no bridge."""
self.driver.on_error = Mock()
self.driver.start_scanning()
self.driver.on_error.assert_called_once()
self.assertTrue(any("Failed to start scanning" in str(msg) for msg, _ in self.log_calls))
def test_start_scanning_success(self):
"""Test that start_scanning() calls bridge and updates state."""
mock_bridge = MagicMock()
self.driver.kotlin_bridge = mock_bridge
self.driver.start_scanning()
mock_bridge.startScanningAsync.assert_called_once()
self.assertEqual(self.driver._state, self.DriverState.SCANNING)
def test_stop_scanning_with_bridge(self):
"""Test that stop_scanning() calls bridge."""
mock_bridge = MagicMock()
self.driver.kotlin_bridge = mock_bridge
self.driver._state = self.DriverState.SCANNING
self.driver.stop_scanning()
mock_bridge.stopScanningAsync.assert_called_once()
self.assertEqual(self.driver._state, self.DriverState.IDLE)
def test_stop_scanning_no_bridge_does_not_raise(self):
"""Test that stop_scanning() without bridge doesn't raise."""
self.driver.kotlin_bridge = None
self.driver._state = self.DriverState.SCANNING
# Should not raise
self.driver.stop_scanning()
def test_stop_scanning_handles_exception(self):
"""Test that stop_scanning() handles exceptions gracefully."""
mock_bridge = MagicMock()
mock_bridge.stopScanningAsync.side_effect = RuntimeError("Bridge error")
self.driver.kotlin_bridge = mock_bridge
# Should not raise
self.driver.stop_scanning()
self.assertTrue(any("Error stopping scan" in str(msg) for msg, _ in self.log_calls))
class TestAndroidBLEDriverAdvertising(unittest.TestCase):
"""
Test advertising methods on the REAL AndroidBLEDriver class.
Tests start_advertising() and stop_advertising().
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
cls.DriverState = MockDriverState
def setUp(self):
"""Set up test fixtures."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver.kotlin_bridge = None
self.driver._state = MockDriverState.IDLE
self.driver.on_error = None
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_start_advertising_no_bridge_raises(self):
"""Test that start_advertising() raises when no bridge."""
self.driver.on_error = Mock()
self.driver.start_advertising("RNS-test", b'\x01' * 16)
self.driver.on_error.assert_called_once()
self.assertTrue(any("Failed to start advertising" in str(msg) for msg, _ in self.log_calls))
def test_start_advertising_success(self):
"""Test that start_advertising() calls bridge and updates state."""
mock_bridge = MagicMock()
self.driver.kotlin_bridge = mock_bridge
self.driver.start_advertising("RNS-test", b'\x01' * 16)
mock_bridge.startAdvertisingAsync.assert_called_once_with("RNS-test")
self.assertEqual(self.driver._state, self.DriverState.ADVERTISING)
def test_stop_advertising_with_bridge(self):
"""Test that stop_advertising() calls bridge."""
mock_bridge = MagicMock()
self.driver.kotlin_bridge = mock_bridge
self.driver._state = self.DriverState.ADVERTISING
self.driver.stop_advertising()
mock_bridge.stopAdvertisingAsync.assert_called_once()
self.assertEqual(self.driver._state, self.DriverState.IDLE)
def test_stop_advertising_no_bridge_does_not_raise(self):
"""Test that stop_advertising() without bridge doesn't raise."""
self.driver.kotlin_bridge = None
self.driver._state = self.DriverState.ADVERTISING
# Should not raise
self.driver.stop_advertising()
def test_stop_advertising_handles_exception(self):
"""Test that stop_advertising() handles exceptions gracefully."""
mock_bridge = MagicMock()
mock_bridge.stopAdvertisingAsync.side_effect = RuntimeError("Bridge error")
self.driver.kotlin_bridge = mock_bridge
# Should not raise
self.driver.stop_advertising()
self.assertTrue(any("Error stopping advertising" in str(msg) for msg, _ in self.log_calls))
class TestAndroidBLEDriverConnection(unittest.TestCase):
"""
Test connection methods on the REAL AndroidBLEDriver class.
Tests should_connect(), connect(), disconnect(), disconnect_central(), disconnect_peripheral().
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def setUp(self):
"""Set up test fixtures."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver.kotlin_bridge = None
self.driver.on_error = None
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_should_connect_no_bridge_returns_false(self):
"""Test that should_connect() returns False when no bridge."""
result = self.driver.should_connect("AA:BB:CC:DD:EE:FF")
self.assertFalse(result)
self.assertTrue(any("no bridge" in str(msg).lower() for msg, _ in self.log_calls))
def test_should_connect_returns_bridge_result(self):
"""Test that should_connect() returns bridge result."""
mock_bridge = MagicMock()
mock_bridge.shouldConnect.return_value = True
self.driver.kotlin_bridge = mock_bridge
result = self.driver.should_connect("AA:BB:CC:DD:EE:FF")
self.assertTrue(result)
mock_bridge.shouldConnect.assert_called_once_with("AA:BB:CC:DD:EE:FF")
def test_should_connect_exception_returns_false(self):
"""Test that should_connect() returns False on exception."""
mock_bridge = MagicMock()
mock_bridge.shouldConnect.side_effect = RuntimeError("Error")
self.driver.kotlin_bridge = mock_bridge
result = self.driver.should_connect("AA:BB:CC:DD:EE:FF")
self.assertFalse(result)
def test_connect_no_bridge_raises(self):
"""Test that connect() calls error callback when no bridge."""
self.driver.on_error = Mock()
self.driver.connect("AA:BB:CC:DD:EE:FF")
self.driver.on_error.assert_called_once()
def test_connect_success(self):
"""Test that connect() calls bridge connectAsync."""
mock_bridge = MagicMock()
self.driver.kotlin_bridge = mock_bridge
self.driver.connect("AA:BB:CC:DD:EE:FF")
mock_bridge.connectAsync.assert_called_once_with("AA:BB:CC:DD:EE:FF")
def test_disconnect_with_bridge(self):
"""Test that disconnect() calls bridge disconnectAsync."""
mock_bridge = MagicMock()
self.driver.kotlin_bridge = mock_bridge
self.driver.disconnect("AA:BB:CC:DD:EE:FF")
mock_bridge.disconnectAsync.assert_called_once_with("AA:BB:CC:DD:EE:FF")
def test_disconnect_no_bridge_does_not_raise(self):
"""Test that disconnect() without bridge doesn't raise."""
# Should not raise
self.driver.disconnect("AA:BB:CC:DD:EE:FF")
def test_disconnect_handles_exception(self):
"""Test that disconnect() handles exceptions gracefully."""
mock_bridge = MagicMock()
mock_bridge.disconnectAsync.side_effect = RuntimeError("Error")
self.driver.kotlin_bridge = mock_bridge
# Should not raise
self.driver.disconnect("AA:BB:CC:DD:EE:FF")
self.assertTrue(any("Error disconnecting" in str(msg) for msg, _ in self.log_calls))
def test_disconnect_central_with_bridge(self):
"""Test that disconnect_central() calls bridge disconnectCentralAsync."""
mock_bridge = MagicMock()
self.driver.kotlin_bridge = mock_bridge
self.driver.disconnect_central("AA:BB:CC:DD:EE:FF")
mock_bridge.disconnectCentralAsync.assert_called_once_with("AA:BB:CC:DD:EE:FF")
def test_disconnect_central_no_bridge_logs_warning(self):
"""Test that disconnect_central() logs warning when no bridge."""
self.driver.disconnect_central("AA:BB:CC:DD:EE:FF")
self.assertTrue(any("no bridge" in str(msg).lower() for msg, _ in self.log_calls))
def test_disconnect_peripheral_with_bridge(self):
"""Test that disconnect_peripheral() calls bridge disconnectPeripheralAsync."""
mock_bridge = MagicMock()
self.driver.kotlin_bridge = mock_bridge
self.driver.disconnect_peripheral("AA:BB:CC:DD:EE:FF")
mock_bridge.disconnectPeripheralAsync.assert_called_once_with("AA:BB:CC:DD:EE:FF")
def test_disconnect_peripheral_no_bridge_logs_warning(self):
"""Test that disconnect_peripheral() logs warning when no bridge."""
self.driver.disconnect_peripheral("AA:BB:CC:DD:EE:FF")
self.assertTrue(any("no bridge" in str(msg).lower() for msg, _ in self.log_calls))
def test_disconnect_central_handles_exception(self):
"""Test that disconnect_central() handles exceptions gracefully."""
mock_bridge = MagicMock()
mock_bridge.disconnectCentralAsync.side_effect = RuntimeError("Error")
self.driver.kotlin_bridge = mock_bridge
# Should not raise
self.driver.disconnect_central("AA:BB:CC:DD:EE:FF")
self.assertTrue(any("Error disconnecting central" in str(msg) for msg, _ in self.log_calls))
def test_disconnect_peripheral_handles_exception(self):
"""Test that disconnect_peripheral() handles exceptions gracefully."""
mock_bridge = MagicMock()
mock_bridge.disconnectPeripheralAsync.side_effect = RuntimeError("Error")
self.driver.kotlin_bridge = mock_bridge
# Should not raise
self.driver.disconnect_peripheral("AA:BB:CC:DD:EE:FF")
self.assertTrue(any("Error disconnecting peripheral" in str(msg) for msg, _ in self.log_calls))
class TestAndroidBLEDriverSend(unittest.TestCase):
"""
Test send() method on the REAL AndroidBLEDriver class.
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def setUp(self):
"""Set up test fixtures."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver.kotlin_bridge = None
self.driver.on_error = None
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_send_no_bridge_calls_error(self):
"""Test that send() calls error callback when no bridge."""
self.driver.on_error = Mock()
self.driver.send("AA:BB:CC:DD:EE:FF", b"test data")
self.driver.on_error.assert_called_once()
def test_send_success(self):
"""Test that send() calls bridge sendAsync."""
mock_bridge = MagicMock()
self.driver.kotlin_bridge = mock_bridge
self.driver.send("AA:BB:CC:DD:EE:FF", b"test data")
mock_bridge.sendAsync.assert_called_once_with("AA:BB:CC:DD:EE:FF", b"test data")
def test_send_handles_exception(self):
"""Test that send() handles exceptions gracefully."""
mock_bridge = MagicMock()
mock_bridge.sendAsync.side_effect = RuntimeError("Error")
self.driver.kotlin_bridge = mock_bridge
self.driver.on_error = Mock()
self.driver.send("AA:BB:CC:DD:EE:FF", b"test data")
self.driver.on_error.assert_called_once()
class TestAndroidBLEDriverGattStubs(unittest.TestCase):
"""
Test GATT stub methods on the REAL AndroidBLEDriver class.
These methods exist for interface compatibility but log warnings.
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def setUp(self):
"""Set up test fixtures."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_read_characteristic_returns_empty_and_logs_warning(self):
"""Test that read_characteristic() returns empty bytes and logs warning."""
result = self.driver.read_characteristic("AA:BB:CC:DD:EE:FF", "char-uuid")
self.assertEqual(result, b"")
self.assertTrue(any("not implemented" in str(msg).lower() for msg, _ in self.log_calls))
def test_write_characteristic_logs_warning(self):
"""Test that write_characteristic() logs warning."""
self.driver.write_characteristic("AA:BB:CC:DD:EE:FF", "char-uuid", b"data")
self.assertTrue(any("not implemented" in str(msg).lower() for msg, _ in self.log_calls))
def test_start_notify_logs_debug(self):
"""Test that start_notify() logs debug message."""
self.driver.start_notify("AA:BB:CC:DD:EE:FF", "char-uuid", Mock())
self.assertTrue(any("not needed" in str(msg).lower() for msg, _ in self.log_calls))
class TestAndroidBLEDriverQueries(unittest.TestCase):
"""
Test query methods on the REAL AndroidBLEDriver class.
Tests state property, connected_peers, get_local_address(), get_peer_role(), get_peer_mtu().
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
cls.DriverState = MockDriverState
def setUp(self):
"""Set up test fixtures."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver._state = MockDriverState.IDLE
self.driver._connected_peers = []
self.driver._peer_roles = {}
self.driver._peer_mtus = {}
def test_state_property(self):
"""Test that state property returns current state."""
self.driver._state = self.DriverState.SCANNING
self.assertEqual(self.driver.state, self.DriverState.SCANNING)
def test_connected_peers_returns_copy(self):
"""Test that connected_peers returns a copy of the list."""
self.driver._connected_peers = ["AA:BB:CC:DD:EE:FF"]
result = self.driver.connected_peers
result.append("11:22:33:44:55:66")
# Original should not be modified
self.assertEqual(len(self.driver._connected_peers), 1)
def test_get_local_address_returns_placeholder(self):
"""Test that get_local_address() returns placeholder."""
result = self.driver.get_local_address()
self.assertEqual(result, "00:00:00:00:00:00")
def test_get_peer_role_returns_role(self):
"""Test that get_peer_role() returns role when present."""
self.driver._peer_roles["AA:BB:CC:DD:EE:FF"] = "central"
result = self.driver.get_peer_role("AA:BB:CC:DD:EE:FF")
self.assertEqual(result, "central")
def test_get_peer_role_returns_none_for_unknown(self):
"""Test that get_peer_role() returns None for unknown peer."""
result = self.driver.get_peer_role("AA:BB:CC:DD:EE:FF")
self.assertIsNone(result)
def test_get_peer_mtu_returns_mtu(self):
"""Test that get_peer_mtu() returns MTU when present."""
self.driver._peer_mtus["AA:BB:CC:DD:EE:FF"] = 512
result = self.driver.get_peer_mtu("AA:BB:CC:DD:EE:FF")
self.assertEqual(result, 512)
def test_get_peer_mtu_returns_none_for_unknown(self):
"""Test that get_peer_mtu() returns None for unknown peer."""
result = self.driver.get_peer_mtu("AA:BB:CC:DD:EE:FF")
self.assertIsNone(result)
class TestAndroidBLEDriverCallbackHandlers(unittest.TestCase):
"""
Test callback handler methods on the REAL AndroidBLEDriver class.
Tests _handle_device_discovered, _handle_connected, _handle_disconnected,
_handle_address_changed, _handle_identity_received, _handle_mtu_negotiated.
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
cls.MockBLEDevice = MagicMock()
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = cls.MockBLEDevice
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def setUp(self):
"""Set up test fixtures."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver._connected_peers = []
self.driver._peer_roles = {}
self.driver._peer_mtus = {}
self.driver._pending_identities = {}
self.driver._identity_lock = threading.Lock()
self.driver._address_to_identity = {}
self.driver._identity_to_address = {}
self.driver.on_device_discovered = None
self.driver.on_device_connected = None
self.driver.on_device_disconnected = None
self.driver.on_data_received = None
self.driver.on_mtu_negotiated = None
self.driver.on_address_changed = None
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_handle_device_discovered_creates_ble_device(self):
"""Test that _handle_device_discovered creates BLEDevice and calls callback."""
received = []
self.driver.on_device_discovered = lambda dev: received.append(dev)
self.driver._handle_device_discovered("AA:BB:CC:DD:EE:FF", "RNS-test", -50, ["uuid1"])
self.assertEqual(len(received), 1)
self.MockBLEDevice.assert_called_once_with(
address="AA:BB:CC:DD:EE:FF",
name="RNS-test",
rssi=-50,
service_uuids=["uuid1"]
)
def test_handle_device_discovered_handles_none_values(self):
"""Test that _handle_device_discovered handles None name and uuids."""
self.driver.on_device_discovered = Mock()
self.MockBLEDevice.reset_mock()
self.driver._handle_device_discovered("AA:BB:CC:DD:EE:FF", None, -50, None)
self.MockBLEDevice.assert_called_once_with(
address="AA:BB:CC:DD:EE:FF",
name="Unknown",
rssi=-50,
service_uuids=[]
)
def test_handle_connected_adds_peer_and_calls_callbacks(self):
"""Test that _handle_connected adds peer and calls callbacks."""
connected_calls = []
mtu_calls = []
self.driver.on_device_connected = lambda addr, identity: connected_calls.append((addr, identity))
self.driver.on_mtu_negotiated = lambda addr, mtu: mtu_calls.append((addr, mtu))
identity_hex = "0102030405060708090a0b0c0d0e0f10"
self.driver._handle_connected("AA:BB:CC:DD:EE:FF", 512, "central", identity_hex)
self.assertIn("AA:BB:CC:DD:EE:FF", self.driver._connected_peers)
self.assertEqual(self.driver._peer_roles["AA:BB:CC:DD:EE:FF"], "central")
self.assertEqual(len(connected_calls), 1)
self.assertEqual(connected_calls[0][0], "AA:BB:CC:DD:EE:FF")
self.assertEqual(connected_calls[0][1], bytes.fromhex(identity_hex))
self.assertEqual(len(mtu_calls), 1)
def test_handle_connected_uses_pending_identity_fallback(self):
"""Test that _handle_connected uses pending identity when no identity_hash."""
connected_calls = []
self.driver.on_device_connected = lambda addr, identity: connected_calls.append((addr, identity))
self.driver.on_mtu_negotiated = Mock()
pending_identity = b'\x01' * 16
self.driver._pending_identities["AA:BB:CC:DD:EE:FF"] = pending_identity
self.driver._handle_connected("AA:BB:CC:DD:EE:FF", 512, "peripheral", None)
self.assertEqual(connected_calls[0][1], pending_identity)
self.assertNotIn("AA:BB:CC:DD:EE:FF", self.driver._pending_identities)
def test_handle_disconnected_cleans_up_state(self):
"""Test that _handle_disconnected cleans up all state."""
address = "AA:BB:CC:DD:EE:FF"
identity_hex = "0102030405060708090a0b0c0d0e0f10"
self.driver._connected_peers = [address]
self.driver._peer_roles[address] = "central"
self.driver._peer_mtus[address] = 512
self.driver._address_to_identity[address] = identity_hex
self.driver._identity_to_address[identity_hex] = address
disconnected_calls = []
self.driver.on_device_disconnected = lambda addr: disconnected_calls.append(addr)
self.driver._handle_disconnected(address)
self.assertNotIn(address, self.driver._connected_peers)
self.assertNotIn(address, self.driver._peer_roles)
self.assertNotIn(address, self.driver._peer_mtus)
self.assertNotIn(address, self.driver._address_to_identity)
self.assertNotIn(identity_hex, self.driver._identity_to_address)
self.assertEqual(disconnected_calls, [address])
def test_handle_address_changed_forwards_callback(self):
"""Test that _handle_address_changed forwards to callback."""
changed_calls = []
self.driver.on_address_changed = lambda old, new, id_hash: changed_calls.append((old, new, id_hash))
self.driver._handle_address_changed("11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF", "ab5609df")
self.assertEqual(len(changed_calls), 1)
self.assertEqual(changed_calls[0], ("11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF", "ab5609df"))
def test_handle_identity_received_caches_when_not_connected(self):
"""Test that _handle_identity_received caches identity when peer not connected."""
identity_hex = "0102030405060708090a0b0c0d0e0f10"
self.driver._handle_identity_received("AA:BB:CC:DD:EE:FF", identity_hex)
self.assertEqual(
self.driver._pending_identities["AA:BB:CC:DD:EE:FF"],
bytes.fromhex(identity_hex)
)
def test_handle_identity_received_notifies_when_already_connected(self):
"""Test that _handle_identity_received notifies when peer already connected."""
connected_calls = []
self.driver.on_device_connected = lambda addr, identity: connected_calls.append((addr, identity))
self.driver._connected_peers = ["AA:BB:CC:DD:EE:FF"]
identity_hex = "0102030405060708090a0b0c0d0e0f10"
self.driver._handle_identity_received("AA:BB:CC:DD:EE:FF", identity_hex)
self.assertEqual(len(connected_calls), 1)
self.assertEqual(connected_calls[0][1], bytes.fromhex(identity_hex))
def test_handle_mtu_negotiated_stores_and_calls_callback(self):
"""Test that _handle_mtu_negotiated stores MTU and calls callback."""
mtu_calls = []
self.driver.on_mtu_negotiated = lambda addr, mtu: mtu_calls.append((addr, mtu))
self.driver._handle_mtu_negotiated("AA:BB:CC:DD:EE:FF", 512)
self.assertEqual(self.driver._peer_mtus["AA:BB:CC:DD:EE:FF"], 512)
self.assertEqual(mtu_calls, [("AA:BB:CC:DD:EE:FF", 512)])
def test_handle_device_discovered_exception_logged(self):
"""Test that _handle_device_discovered logs exceptions."""
# Set callback that raises exception
def bad_callback(dev):
raise RuntimeError("Test error")
self.driver.on_device_discovered = bad_callback
# Should not raise
self.driver._handle_device_discovered("AA:BB:CC:DD:EE:FF", "test", -50, [])
self.assertTrue(any("Error handling device discovered" in str(msg) for msg, _ in self.log_calls))
def test_handle_connected_exception_logged(self):
"""Test that _handle_connected logs exceptions."""
def bad_callback(addr, identity):
raise RuntimeError("Test error")
self.driver.on_device_connected = bad_callback
self.driver.on_mtu_negotiated = Mock()
# Should not raise
self.driver._handle_connected("AA:BB:CC:DD:EE:FF", 512, "central", "0102030405060708090a0b0c0d0e0f10")
self.assertTrue(any("Error handling connected" in str(msg) for msg, _ in self.log_calls))
def test_handle_disconnected_exception_logged(self):
"""Test that _handle_disconnected logs exceptions."""
def bad_callback(addr):
raise RuntimeError("Test error")
self.driver._connected_peers = ["AA:BB:CC:DD:EE:FF"]
self.driver.on_device_disconnected = bad_callback
# Should not raise
self.driver._handle_disconnected("AA:BB:CC:DD:EE:FF")
self.assertTrue(any("Error handling disconnected" in str(msg) for msg, _ in self.log_calls))
def test_handle_address_changed_exception_logged(self):
"""Test that _handle_address_changed logs exceptions."""
def bad_callback(old, new, id_hash):
raise RuntimeError("Test error")
self.driver.on_address_changed = bad_callback
# Should not raise
self.driver._handle_address_changed("11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF", "ab5609df")
self.assertTrue(any("Error handling address changed" in str(msg) for msg, _ in self.log_calls))
def test_handle_identity_received_exception_logged(self):
"""Test that _handle_identity_received logs exceptions."""
def bad_callback(addr, identity):
raise RuntimeError("Test error")
self.driver._connected_peers = ["AA:BB:CC:DD:EE:FF"]
self.driver.on_device_connected = bad_callback
# Should not raise
self.driver._handle_identity_received("AA:BB:CC:DD:EE:FF", "0102030405060708090a0b0c0d0e0f10")
self.assertTrue(any("Error handling identity received" in str(msg) for msg, _ in self.log_calls))
def test_handle_mtu_negotiated_exception_logged(self):
"""Test that _handle_mtu_negotiated logs exceptions."""
def bad_callback(addr, mtu):
raise RuntimeError("Test error")
self.driver.on_mtu_negotiated = bad_callback
# Should not raise
self.driver._handle_mtu_negotiated("AA:BB:CC:DD:EE:FF", 512)
self.assertTrue(any("Error handling MTU negotiated" in str(msg) for msg, _ in self.log_calls))
def test_handle_data_received_exception_logged(self):
"""Test that _handle_data_received logs exceptions."""
def bad_callback(addr, data):
raise RuntimeError("Test error")
self.driver._connected_peers = ["AA:BB:CC:DD:EE:FF"]
self.driver.on_data_received = bad_callback
# Should not raise
self.driver._handle_data_received("AA:BB:CC:DD:EE:FF", b"data")
self.assertTrue(any("Error handling data received" in str(msg) for msg, _ in self.log_calls))
def test_handle_connected_invalid_identity_hash_logs_warning(self):
"""Test that _handle_connected logs warning for invalid identity_hash."""
self.driver.on_device_connected = Mock()
self.driver.on_mtu_negotiated = Mock()
# Invalid hex string
self.driver._handle_connected("AA:BB:CC:DD:EE:FF", 512, "central", "not_valid_hex")
self.assertTrue(any("Invalid identity_hash format" in str(msg) for msg, _ in self.log_calls))
class TestAndroidBLEDriverKotlinBridge(unittest.TestCase):
"""
Test _get_kotlin_bridge() method on the REAL AndroidBLEDriver class.
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def setUp(self):
"""Set up test fixtures."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
# Save original reticulum_wrapper module for restoration
self._original_reticulum_wrapper = sys.modules.get('reticulum_wrapper')
def tearDown(self):
"""Restore original RNS.log and reticulum_wrapper module."""
self.abd_module.RNS.log = self._original_log
# Restore original reticulum_wrapper module to prevent test isolation issues
if self._original_reticulum_wrapper is not None:
sys.modules['reticulum_wrapper'] = self._original_reticulum_wrapper
elif 'reticulum_wrapper' in sys.modules:
# If original was not present but something is now, remove it
del sys.modules['reticulum_wrapper']
def test_get_kotlin_bridge_success(self):
"""Test that _get_kotlin_bridge returns bridge from wrapper."""
mock_bridge = MagicMock()
mock_wrapper = MagicMock()
mock_wrapper.kotlin_ble_bridge = mock_bridge
mock_module = MagicMock()
mock_module._global_wrapper_instance = mock_wrapper
sys.modules['reticulum_wrapper'] = mock_module
result = self.driver._get_kotlin_bridge()
self.assertEqual(result, mock_bridge)
def test_get_kotlin_bridge_no_wrapper_returns_none(self):
"""Test that _get_kotlin_bridge returns None when no wrapper."""
mock_module = MagicMock()
mock_module._global_wrapper_instance = None
sys.modules['reticulum_wrapper'] = mock_module
result = self.driver._get_kotlin_bridge()
self.assertIsNone(result)
self.assertTrue(any("No global wrapper" in str(msg) for msg, _ in self.log_calls))
def test_get_kotlin_bridge_no_bridge_in_wrapper_returns_none(self):
"""Test that _get_kotlin_bridge returns None when wrapper has no bridge."""
mock_wrapper = MagicMock()
mock_wrapper.kotlin_ble_bridge = None
mock_module = MagicMock()
mock_module._global_wrapper_instance = mock_wrapper
sys.modules['reticulum_wrapper'] = mock_module
result = self.driver._get_kotlin_bridge()
self.assertIsNone(result)
self.assertTrue(any("No BLE bridge set" in str(msg) for msg, _ in self.log_calls))
def test_get_kotlin_bridge_import_error_returns_none(self):
"""Test that _get_kotlin_bridge returns None on import error."""
# Remove reticulum_wrapper to cause ImportError
if 'reticulum_wrapper' in sys.modules:
del sys.modules['reticulum_wrapper']
# Patch import to raise ImportError
with patch.dict(sys.modules, {'reticulum_wrapper': None}):
result = self.driver._get_kotlin_bridge()
self.assertIsNone(result)
class TestAndroidBLEDriverSetupKotlinCallbacks(unittest.TestCase):
"""
Test _setup_kotlin_callbacks() method on the REAL AndroidBLEDriver class.
"""
@classmethod
def setUpClass(cls):
"""Import the real AndroidBLEDriver class."""
if 'bluetooth_driver' in sys.modules:
del sys.modules['bluetooth_driver']
if 'android_ble_driver' in sys.modules:
del sys.modules['android_ble_driver']
class MockBLEDriverInterface:
pass
mock_bt_driver = MagicMock()
mock_bt_driver.BLEDriverInterface = MockBLEDriverInterface
mock_bt_driver.DriverState = MockDriverState
mock_bt_driver.BLEDevice = MagicMock()
sys.modules['bluetooth_driver'] = mock_bt_driver
ble_modules_dir = os.path.join(os.path.dirname(__file__), 'ble_modules')
if ble_modules_dir not in sys.path:
sys.path.insert(0, ble_modules_dir)
import android_ble_driver as abd_module
cls.AndroidBLEDriver = abd_module.AndroidBLEDriver
cls.abd_module = abd_module
def setUp(self):
"""Set up test fixtures."""
self.driver = object.__new__(self.AndroidBLEDriver)
self.driver._connected_peers = []
self.driver._peer_roles = {}
self.driver._peer_mtus = {}
self.driver._pending_identities = {}
self.driver._identity_lock = threading.Lock()
self.driver._address_to_identity = {}
self.driver._identity_to_address = {}
self.driver.kotlin_bridge = None
self.log_calls = []
self._original_log = self.abd_module.RNS.log
self.abd_module.RNS.log = lambda msg, level=4: self.log_calls.append((msg, level))
def tearDown(self):
"""Restore original RNS.log."""
self.abd_module.RNS.log = self._original_log
def test_setup_kotlin_callbacks_no_bridge_returns_early(self):
"""Test that _setup_kotlin_callbacks returns early when no bridge."""
self.driver.kotlin_bridge = None
# Should not raise
self.driver._setup_kotlin_callbacks()
def test_setup_kotlin_callbacks_wires_all_callbacks(self):
"""Test that _setup_kotlin_callbacks wires all expected callbacks."""
mock_bridge = MagicMock()
self.driver.kotlin_bridge = mock_bridge
self.driver._setup_kotlin_callbacks()
# Verify all callbacks are wired
mock_bridge.setOnDeviceDiscovered.assert_called_once()
mock_bridge.setOnConnected.assert_called_once()
mock_bridge.setOnDisconnected.assert_called_once()
mock_bridge.setOnDataReceived.assert_called_once()
mock_bridge.setOnIdentityReceived.assert_called_once()
mock_bridge.setOnMtuNegotiated.assert_called_once()
mock_bridge.setOnAddressChanged.assert_called_once()
mock_bridge.setOnDuplicateIdentityDetected.assert_called_once()
if __name__ == '__main__':
unittest.main()