diff --git a/apps/bench.py b/apps/bench.py
index 8b378831..a98adc47 100644
--- a/apps/bench.py
+++ b/apps/bench.py
@@ -50,8 +50,10 @@ from bumble.sdp import (
SDP_PUBLIC_BROWSE_ROOT,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement,
ServiceAttribute,
+ Client as SdpClient,
)
from bumble.transport import open_transport_or_link
import bumble.rfcomm
@@ -77,6 +79,7 @@ SPEED_SERVICE_UUID = '50DB505C-8AC4-4738-8448-3B1D9CC09CC5'
SPEED_TX_UUID = 'E789C754-41A1-45F4-A948-A0A1A90DBA53'
SPEED_RX_UUID = '016A2CC7-E14B-4819-935F-1F56EAE4098D'
+DEFAULT_RFCOMM_UUID = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
DEFAULT_L2CAP_PSM = 1234
DEFAULT_L2CAP_MAX_CREDITS = 128
DEFAULT_L2CAP_MTU = 1022
@@ -128,11 +131,16 @@ def print_connection(connection):
if connection.transport == BT_LE_TRANSPORT:
phy_state = (
'PHY='
- f'RX:{le_phy_name(connection.phy.rx_phy)}/'
- f'TX:{le_phy_name(connection.phy.tx_phy)}'
+ f'TX:{le_phy_name(connection.phy.tx_phy)}/'
+ f'RX:{le_phy_name(connection.phy.rx_phy)}'
)
- data_length = f'DL={connection.data_length}'
+ data_length = (
+ 'DL=('
+ f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
+ f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
+ ')'
+ )
connection_parameters = (
'Parameters='
f'{connection.parameters.connection_interval * 1.25:.2f}/'
@@ -169,9 +177,7 @@ def make_sdp_records(channel):
),
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
- DataElement.sequence(
- [DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
- ),
+ DataElement.sequence([DataElement.uuid(UUID(DEFAULT_RFCOMM_UUID))]),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
@@ -191,6 +197,48 @@ def make_sdp_records(channel):
}
+async def find_rfcomm_channel_with_uuid(connection: Connection, uuid: str) -> int:
+ # Connect to the SDP Server
+ sdp_client = SdpClient(connection)
+ await sdp_client.connect()
+
+ # Search for services with an L2CAP service attribute
+ search_result = await sdp_client.search_attributes(
+ [BT_L2CAP_PROTOCOL_ID],
+ [
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ ],
+ )
+ for attribute_list in search_result:
+ service_uuid = None
+ service_class_id_list = ServiceAttribute.find_attribute_in_list(
+ attribute_list, SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID
+ )
+ if service_class_id_list:
+ if service_class_id_list.value:
+ for service_class_id in service_class_id_list.value:
+ service_uuid = service_class_id.value
+ if str(service_uuid) != uuid:
+ # This service doesn't have a UUID or isn't the right one.
+ continue
+
+ # Look for the RFCOMM Channel number
+ protocol_descriptor_list = ServiceAttribute.find_attribute_in_list(
+ attribute_list, SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID
+ )
+ if protocol_descriptor_list:
+ for protocol_descriptor in protocol_descriptor_list.value:
+ if len(protocol_descriptor.value) >= 2:
+ if protocol_descriptor.value[0].value == BT_RFCOMM_PROTOCOL_ID:
+ await sdp_client.disconnect()
+ return protocol_descriptor.value[1].value
+
+ await sdp_client.disconnect()
+ return 0
+
+
class PacketType(enum.IntEnum):
RESET = 0
SEQUENCE = 1
@@ -224,7 +272,7 @@ class Sender:
if self.tx_start_delay:
print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
- await asyncio.sleep(self.tx_start_delay) # FIXME
+ await asyncio.sleep(self.tx_start_delay)
print(color('=== Sending RESET', 'magenta'))
await self.packet_io.send_packet(bytes([PacketType.RESET]))
@@ -364,7 +412,7 @@ class Ping:
if self.tx_start_delay:
print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
- await asyncio.sleep(self.tx_start_delay) # FIXME
+ await asyncio.sleep(self.tx_start_delay)
print(color('=== Sending RESET', 'magenta'))
await self.packet_io.send_packet(bytes([PacketType.RESET]))
@@ -710,14 +758,14 @@ class L2capServer(StreamedPacketIO):
self.l2cap_channel = None
self.ready = asyncio.Event()
- # Listen for incoming L2CAP CoC connections
+ # Listen for incoming L2CAP connections
device.create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(
psm=psm, mtu=mtu, mps=mps, max_credits=max_credits
),
handler=self.on_l2cap_channel,
)
- print(color(f'### Listening for CoC connection on PSM {psm}', 'yellow'))
+ print(color(f'### Listening for L2CAP connection on PSM {psm}', 'yellow'))
async def on_connection(self, connection):
connection.on('disconnection', self.on_disconnection)
@@ -743,21 +791,35 @@ class L2capServer(StreamedPacketIO):
# RfcommClient
# -----------------------------------------------------------------------------
class RfcommClient(StreamedPacketIO):
- def __init__(self, device):
+ def __init__(self, device, channel, uuid):
super().__init__()
self.device = device
+ self.channel = channel
+ self.uuid = uuid
self.ready = asyncio.Event()
async def on_connection(self, connection):
connection.on('disconnection', self.on_disconnection)
+ # Find the channel number if not specified
+ channel = self.channel
+ if channel == 0:
+ print(
+ color(f'@@@ Discovering channel number from UUID {self.uuid}', 'cyan')
+ )
+ channel = await find_rfcomm_channel_with_uuid(connection, self.uuid)
+ print(color(f'@@@ Channel number = {channel}', 'cyan'))
+ if channel == 0:
+ print(color('!!! No RFComm service with this UUID found', 'red'))
+ await connection.disconnect()
+ return
+
# Create a client and start it
print(color('*** Starting RFCOMM client...', 'blue'))
- rfcomm_client = bumble.rfcomm.Client(self.device, connection)
+ rfcomm_client = bumble.rfcomm.Client(connection)
rfcomm_mux = await rfcomm_client.start()
print(color('*** Started', 'blue'))
- channel = DEFAULT_RFCOMM_CHANNEL
print(color(f'### Opening session for channel {channel}...', 'yellow'))
try:
rfcomm_session = await rfcomm_mux.open_dlc(channel)
@@ -780,7 +842,7 @@ class RfcommClient(StreamedPacketIO):
# RfcommServer
# -----------------------------------------------------------------------------
class RfcommServer(StreamedPacketIO):
- def __init__(self, device):
+ def __init__(self, device, channel):
super().__init__()
self.ready = asyncio.Event()
@@ -788,7 +850,7 @@ class RfcommServer(StreamedPacketIO):
rfcomm_server = bumble.rfcomm.Server(device)
# Listen for incoming DLC connections
- channel_number = rfcomm_server.listen(self.on_dlc, DEFAULT_RFCOMM_CHANNEL)
+ channel_number = rfcomm_server.listen(self.on_dlc, channel)
# Setup the SDP to advertise this channel
device.sdp_service_records = make_sdp_records(channel_number)
@@ -825,6 +887,9 @@ class Central(Connection.Listener):
mode_factory,
connection_interval,
phy,
+ authenticate,
+ encrypt,
+ extended_data_length,
):
super().__init__()
self.transport = transport
@@ -832,6 +897,9 @@ class Central(Connection.Listener):
self.classic = classic
self.role_factory = role_factory
self.mode_factory = mode_factory
+ self.authenticate = authenticate
+ self.encrypt = encrypt or authenticate
+ self.extended_data_length = extended_data_length
self.device = None
self.connection = None
@@ -904,7 +972,26 @@ class Central(Connection.Listener):
self.connection.listener = self
print_connection(self.connection)
- await mode.on_connection(self.connection)
+ # Request a new data length if requested
+ if self.extended_data_length:
+ print(color('+++ Requesting extended data length', 'cyan'))
+ await self.connection.set_data_length(
+ self.extended_data_length[0], self.extended_data_length[1]
+ )
+
+ # Authenticate if requested
+ if self.authenticate:
+ # Request authentication
+ print(color('*** Authenticating...', 'cyan'))
+ await self.connection.authenticate()
+ print(color('*** Authenticated', 'cyan'))
+
+ # Encrypt if requested
+ if self.encrypt:
+ # Enable encryption
+ print(color('*** Enabling encryption...', 'cyan'))
+ await self.connection.encrypt()
+ print(color('*** Encryption on', 'cyan'))
# Set the PHY if requested
if self.phy is not None:
@@ -919,6 +1006,8 @@ class Central(Connection.Listener):
)
)
+ await mode.on_connection(self.connection)
+
await role.run()
await asyncio.sleep(DEFAULT_LINGER_TIME)
@@ -943,9 +1032,12 @@ class Central(Connection.Listener):
# Peripheral
# -----------------------------------------------------------------------------
class Peripheral(Device.Listener, Connection.Listener):
- def __init__(self, transport, classic, role_factory, mode_factory):
+ def __init__(
+ self, transport, classic, extended_data_length, role_factory, mode_factory
+ ):
self.transport = transport
self.classic = classic
+ self.extended_data_length = extended_data_length
self.role_factory = role_factory
self.role = None
self.mode_factory = mode_factory
@@ -1006,6 +1098,15 @@ class Peripheral(Device.Listener, Connection.Listener):
self.connection = connection
self.connected.set()
+ # Request a new data length if needed
+ if self.extended_data_length:
+ print("+++ Requesting extended data length")
+ AsyncRunner.spawn(
+ connection.set_data_length(
+ self.extended_data_length[0], self.extended_data_length[1]
+ )
+ )
+
def on_disconnection(self, reason):
print(color(f'!!! Disconnection: reason={reason}', 'red'))
self.connection = None
@@ -1038,16 +1139,18 @@ def create_mode_factory(ctx, default_mode):
return GattServer(device)
if mode == 'l2cap-client':
- return L2capClient(device)
+ return L2capClient(device, psm=ctx.obj['l2cap_psm'])
if mode == 'l2cap-server':
- return L2capServer(device)
+ return L2capServer(device, psm=ctx.obj['l2cap_psm'])
if mode == 'rfcomm-client':
- return RfcommClient(device)
+ return RfcommClient(
+ device, channel=ctx.obj['rfcomm_channel'], uuid=ctx.obj['rfcomm_uuid']
+ )
if mode == 'rfcomm-server':
- return RfcommServer(device)
+ return RfcommServer(device, channel=ctx.obj['rfcomm_channel'])
raise ValueError('invalid mode')
@@ -1113,6 +1216,27 @@ def create_role_factory(ctx, default_role):
type=click.IntRange(23, 517),
help='GATT MTU (gatt-client mode)',
)
+@click.option(
+ '--extended-data-length',
+ help='Request a data length upon connection, specified as tx_octets/tx_time',
+)
+@click.option(
+ '--rfcomm-channel',
+ type=int,
+ default=DEFAULT_RFCOMM_CHANNEL,
+ help='RFComm channel to use',
+)
+@click.option(
+ '--rfcomm-uuid',
+ default=DEFAULT_RFCOMM_UUID,
+ help='RFComm service UUID to use (ignored is --rfcomm-channel is not 0)',
+)
+@click.option(
+ '--l2cap-psm',
+ type=int,
+ default=DEFAULT_L2CAP_PSM,
+ help='L2CAP PSM to use',
+)
@click.option(
'--packet-size',
'-s',
@@ -1139,17 +1263,36 @@ def create_role_factory(ctx, default_role):
)
@click.pass_context
def bench(
- ctx, device_config, role, mode, att_mtu, packet_size, packet_count, start_delay
+ ctx,
+ device_config,
+ role,
+ mode,
+ att_mtu,
+ extended_data_length,
+ packet_size,
+ packet_count,
+ start_delay,
+ rfcomm_channel,
+ rfcomm_uuid,
+ l2cap_psm,
):
ctx.ensure_object(dict)
ctx.obj['device_config'] = device_config
ctx.obj['role'] = role
ctx.obj['mode'] = mode
ctx.obj['att_mtu'] = att_mtu
+ ctx.obj['rfcomm_channel'] = rfcomm_channel
+ ctx.obj['rfcomm_uuid'] = rfcomm_uuid
+ ctx.obj['l2cap_psm'] = l2cap_psm
ctx.obj['packet_size'] = packet_size
ctx.obj['packet_count'] = packet_count
ctx.obj['start_delay'] = start_delay
+ ctx.obj['extended_data_length'] = (
+ [int(x) for x in extended_data_length.split('/')]
+ if extended_data_length
+ else None
+ )
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
@@ -1170,8 +1313,12 @@ def bench(
help='Connection interval (in ms)',
)
@click.option('--phy', type=click.Choice(['1m', '2m', 'coded']), help='PHY to use')
+@click.option('--authenticate', is_flag=True, help='Authenticate (RFComm only)')
+@click.option('--encrypt', is_flag=True, help='Encrypt the connection (RFComm only)')
@click.pass_context
-def central(ctx, transport, peripheral_address, connection_interval, phy):
+def central(
+ ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
+):
"""Run as a central (initiates the connection)"""
role_factory = create_role_factory(ctx, 'sender')
mode_factory = create_mode_factory(ctx, 'gatt-client')
@@ -1186,6 +1333,9 @@ def central(ctx, transport, peripheral_address, connection_interval, phy):
mode_factory,
connection_interval,
phy,
+ authenticate,
+ encrypt or authenticate,
+ ctx.obj['extended_data_length'],
).run()
)
@@ -1199,7 +1349,13 @@ def peripheral(ctx, transport):
mode_factory = create_mode_factory(ctx, 'gatt-server')
asyncio.run(
- Peripheral(transport, ctx.obj['classic'], role_factory, mode_factory).run()
+ Peripheral(
+ transport,
+ ctx.obj['classic'],
+ ctx.obj['extended_data_length'],
+ role_factory,
+ mode_factory,
+ ).run()
)
diff --git a/apps/controller_info.py b/apps/controller_info.py
index 5be4f3d1..1e02a322 100644
--- a/apps/controller_info.py
+++ b/apps/controller_info.py
@@ -42,6 +42,8 @@ from bumble.hci import (
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command,
HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
+ HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
+ HCI_LE_Read_Suggested_Default_Data_Length_Command,
)
from bumble.host import Host
from bumble.transport import open_transport_or_link
@@ -117,6 +119,18 @@ async def get_le_info(host):
'\n',
)
+ if host.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
+ response = await host.send_command(
+ HCI_LE_Read_Suggested_Default_Data_Length_Command()
+ )
+ if command_succeeded(response):
+ print(
+ color('Suggested Default Data Length:', 'yellow'),
+ f'{response.return_parameters.suggested_max_tx_octets}/'
+ f'{response.return_parameters.suggested_max_tx_time}',
+ '\n',
+ )
+
print(color('LE Features:', 'yellow'))
for feature in host.supported_le_features:
print(' ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))
diff --git a/bumble/avdtp.py b/bumble/avdtp.py
index 9a332f45..103597f9 100644
--- a/bumble/avdtp.py
+++ b/bumble/avdtp.py
@@ -250,15 +250,15 @@ async def find_avdtp_service_with_sdp_client(
# -----------------------------------------------------------------------------
async def find_avdtp_service_with_connection(
- device: device.Device, connection: device.Connection
+ connection: device.Connection,
) -> Optional[Tuple[int, int]]:
'''
Find an AVDTP service, for a connection, and return its version,
or None if none is found
'''
- sdp_client = sdp.Client(device)
- await sdp_client.connect(connection)
+ sdp_client = sdp.Client(connection)
+ await sdp_client.connect()
service_version = await find_avdtp_service_with_sdp_client(sdp_client)
await sdp_client.disconnect()
diff --git a/bumble/device.py b/bumble/device.py
index 45e919de..f343a4b5 100644
--- a/bumble/device.py
+++ b/bumble/device.py
@@ -103,6 +103,7 @@ from .hci import (
HCI_LE_Set_Advertising_Data_Command,
HCI_LE_Set_Advertising_Enable_Command,
HCI_LE_Set_Advertising_Parameters_Command,
+ HCI_LE_Set_Data_Length_Command,
HCI_LE_Set_Default_PHY_Command,
HCI_LE_Set_Extended_Scan_Enable_Command,
HCI_LE_Set_Extended_Scan_Parameters_Command,
@@ -736,6 +737,9 @@ class Connection(CompositeEventEmitter):
self.remove_listener('disconnection', abort.set_result)
self.remove_listener('disconnection_failure', abort.set_exception)
+ async def set_data_length(self, tx_octets, tx_time) -> None:
+ return await self.device.set_data_length(self, tx_octets, tx_time)
+
async def update_parameters(
self,
connection_interval_min,
@@ -2193,6 +2197,22 @@ class Device(CompositeEventEmitter):
)
self.disconnecting = False
+ async def set_data_length(self, connection, tx_octets, tx_time) -> None:
+ if tx_octets < 0x001B or tx_octets > 0x00FB:
+ raise ValueError('tx_octets must be between 0x001B and 0x00FB')
+
+ if tx_time < 0x0148 or tx_time > 0x4290:
+ raise ValueError('tx_time must be between 0x0148 and 0x4290')
+
+ return await self.send_command(
+ HCI_LE_Set_Data_Length_Command(
+ connection_handle=connection.handle,
+ tx_octets=tx_octets,
+ tx_time=tx_time,
+ ), # type: ignore[call-arg]
+ check_result=True,
+ )
+
async def update_connection_parameters(
self,
connection,
diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py
index 53e98e0b..90b28afa 100644
--- a/bumble/rfcomm.py
+++ b/bumble/rfcomm.py
@@ -889,8 +889,7 @@ class Client:
multiplexer: Optional[Multiplexer]
l2cap_channel: Optional[l2cap.ClassicChannel]
- def __init__(self, device: Device, connection: Connection) -> None:
- self.device = device
+ def __init__(self, connection: Connection) -> None:
self.connection = connection
self.l2cap_channel = None
self.multiplexer = None
@@ -906,7 +905,7 @@ class Client:
raise
assert self.l2cap_channel is not None
- # Create a mutliplexer to manage DLCs with the server
+ # Create a multiplexer to manage DLCs with the server
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
# Connect the multiplexer
diff --git a/bumble/sdp.py b/bumble/sdp.py
index bc8303c8..099efabb 100644
--- a/bumble/sdp.py
+++ b/bumble/sdp.py
@@ -760,13 +760,13 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
class Client:
channel: Optional[l2cap.ClassicChannel]
- def __init__(self, device: Device) -> None:
- self.device = device
+ def __init__(self, connection: Connection) -> None:
+ self.connection = connection
self.pending_request = None
self.channel = None
- async def connect(self, connection: Connection) -> None:
- self.channel = await connection.create_l2cap_channel(
+ async def connect(self) -> None:
+ self.channel = await self.connection.create_l2cap_channel(
spec=l2cap.ClassicChannelSpec(SDP_PSM)
)
diff --git a/bumble/transport/usb.py b/bumble/transport/usb.py
index ccc82c19..d48b2392 100644
--- a/bumble/transport/usb.py
+++ b/bumble/transport/usb.py
@@ -24,9 +24,10 @@ import platform
import usb1
-from .common import Transport, ParserSource
-from .. import hci
-from ..colors import color
+from bumble.transport.common import Transport, ParserSource
+from bumble import hci
+from bumble.colors import color
+from bumble.utils import AsyncRunner
# -----------------------------------------------------------------------------
@@ -113,7 +114,7 @@ async def open_usb_transport(spec: str) -> Transport:
def __init__(self, device, acl_out):
self.device = device
self.acl_out = acl_out
- self.transfer = device.getTransfer()
+ self.acl_out_transfer = device.getTransfer()
self.packets = collections.deque() # Queue of packets waiting to be sent
self.loop = asyncio.get_running_loop()
self.cancel_done = self.loop.create_future()
@@ -137,21 +138,20 @@ async def open_usb_transport(spec: str) -> Transport:
# The queue was previously empty, re-prime the pump
self.process_queue()
- def on_packet_sent(self, transfer):
+ def transfer_callback(self, transfer):
status = transfer.getStatus()
- # logger.debug(f'<<< USB out transfer callback: status={status}')
# pylint: disable=no-member
if status == usb1.TRANSFER_COMPLETED:
- self.loop.call_soon_threadsafe(self.on_packet_sent_)
+ self.loop.call_soon_threadsafe(self.on_packet_sent)
elif status == usb1.TRANSFER_CANCELLED:
self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
else:
logger.warning(
- color(f'!!! out transfer not completed: status={status}', 'red')
+ color(f'!!! OUT transfer not completed: status={status}', 'red')
)
- def on_packet_sent_(self):
+ def on_packet_sent(self):
if self.packets:
self.packets.popleft()
self.process_queue()
@@ -163,22 +163,20 @@ async def open_usb_transport(spec: str) -> Transport:
packet = self.packets[0]
packet_type = packet[0]
if packet_type == hci.HCI_ACL_DATA_PACKET:
- self.transfer.setBulk(
- self.acl_out, packet[1:], callback=self.on_packet_sent
+ self.acl_out_transfer.setBulk(
+ self.acl_out, packet[1:], callback=self.transfer_callback
)
- logger.debug('submit ACL')
- self.transfer.submit()
+ self.acl_out_transfer.submit()
elif packet_type == hci.HCI_COMMAND_PACKET:
- self.transfer.setControl(
+ self.acl_out_transfer.setControl(
USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
0,
0,
0,
packet[1:],
- callback=self.on_packet_sent,
+ callback=self.transfer_callback,
)
- logger.debug('submit COMMAND')
- self.transfer.submit()
+ self.acl_out_transfer.submit()
else:
logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
@@ -193,11 +191,11 @@ async def open_usb_transport(spec: str) -> Transport:
self.packets.clear()
# If we have a transfer in flight, cancel it
- if self.transfer.isSubmitted():
+ if self.acl_out_transfer.isSubmitted():
# Try to cancel the transfer, but that may fail because it may have
# already completed
try:
- self.transfer.cancel()
+ self.acl_out_transfer.cancel()
logger.debug('waiting for OUT transfer cancellation to be done...')
await self.cancel_done
@@ -206,27 +204,22 @@ async def open_usb_transport(spec: str) -> Transport:
logger.debug('OUT transfer likely already completed')
class UsbPacketSource(asyncio.Protocol, ParserSource):
- def __init__(self, context, device, metadata, acl_in, events_in):
+ def __init__(self, device, metadata, acl_in, events_in):
super().__init__()
- self.context = context
self.device = device
self.metadata = metadata
self.acl_in = acl_in
+ self.acl_in_transfer = None
self.events_in = events_in
+ self.events_in_transfer = None
self.loop = asyncio.get_running_loop()
self.queue = asyncio.Queue()
self.dequeue_task = None
- self.closed = False
- self.event_loop_done = self.loop.create_future()
self.cancel_done = {
hci.HCI_EVENT_PACKET: self.loop.create_future(),
hci.HCI_ACL_DATA_PACKET: self.loop.create_future(),
}
- self.events_in_transfer = None
- self.acl_in_transfer = None
-
- # Create a thread to process events
- self.event_thread = threading.Thread(target=self.run)
+ self.closed = False
def start(self):
# Set up transfer objects for input
@@ -234,7 +227,7 @@ async def open_usb_transport(spec: str) -> Transport:
self.events_in_transfer.setInterrupt(
self.events_in,
READ_SIZE,
- callback=self.on_packet_received,
+ callback=self.transfer_callback,
user_data=hci.HCI_EVENT_PACKET,
)
self.events_in_transfer.submit()
@@ -243,22 +236,23 @@ async def open_usb_transport(spec: str) -> Transport:
self.acl_in_transfer.setBulk(
self.acl_in,
READ_SIZE,
- callback=self.on_packet_received,
+ callback=self.transfer_callback,
user_data=hci.HCI_ACL_DATA_PACKET,
)
self.acl_in_transfer.submit()
self.dequeue_task = self.loop.create_task(self.dequeue())
- self.event_thread.start()
- def on_packet_received(self, transfer):
+ @property
+ def usb_transfer_submitted(self):
+ return (
+ self.events_in_transfer.isSubmitted()
+ or self.acl_in_transfer.isSubmitted()
+ )
+
+ def transfer_callback(self, transfer):
packet_type = transfer.getUserData()
status = transfer.getStatus()
- # logger.debug(
- # f'<<< USB IN transfer callback: status={status} '
- # f'packet_type={packet_type} '
- # f'length={transfer.getActualLength()}'
- # )
# pylint: disable=no-member
if status == usb1.TRANSFER_COMPLETED:
@@ -267,18 +261,18 @@ async def open_usb_transport(spec: str) -> Transport:
+ transfer.getBuffer()[: transfer.getActualLength()]
)
self.loop.call_soon_threadsafe(self.queue.put_nowait, packet)
+
+ # Re-submit the transfer so we can receive more data
+ transfer.submit()
elif status == usb1.TRANSFER_CANCELLED:
self.loop.call_soon_threadsafe(
self.cancel_done[packet_type].set_result, None
)
- return
else:
logger.warning(
- color(f'!!! transfer not completed: status={status}', 'red')
+ color(f'!!! IN transfer not completed: status={status}', 'red')
)
-
- # Re-submit the transfer so we can receive more data
- transfer.submit()
+ self.loop.call_soon_threadsafe(self.on_transport_lost)
async def dequeue(self):
while not self.closed:
@@ -288,21 +282,6 @@ async def open_usb_transport(spec: str) -> Transport:
return
self.parser.feed_data(packet)
- def run(self):
- logger.debug('starting USB event loop')
- while (
- self.events_in_transfer.isSubmitted()
- or self.acl_in_transfer.isSubmitted()
- ):
- # pylint: disable=no-member
- try:
- self.context.handleEvents()
- except usb1.USBErrorInterrupted:
- pass
-
- logger.debug('USB event loop done')
- self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
-
def close(self):
self.closed = True
@@ -331,15 +310,14 @@ async def open_usb_transport(spec: str) -> Transport:
f'IN[{packet_type}] transfer likely already completed'
)
- # Wait for the thread to terminate
- await self.event_loop_done
-
class UsbTransport(Transport):
def __init__(self, context, device, interface, setting, source, sink):
super().__init__(source, sink)
self.context = context
self.device = device
self.interface = interface
+ self.loop = asyncio.get_running_loop()
+ self.event_loop_done = self.loop.create_future()
# Get exclusive access
device.claimInterface(interface)
@@ -352,6 +330,22 @@ async def open_usb_transport(spec: str) -> Transport:
source.start()
sink.start()
+ # Create a thread to process events
+ self.event_thread = threading.Thread(target=self.run)
+ self.event_thread.start()
+
+ def run(self):
+ logger.debug('starting USB event loop')
+ while self.source.usb_transfer_submitted:
+ # pylint: disable=no-member
+ try:
+ self.context.handleEvents()
+ except usb1.USBErrorInterrupted:
+ pass
+
+ logger.debug('USB event loop done')
+ self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
+
async def close(self):
self.source.close()
self.sink.close()
@@ -361,6 +355,9 @@ async def open_usb_transport(spec: str) -> Transport:
self.device.close()
self.context.close()
+ # Wait for the thread to terminate
+ await self.event_loop_done
+
# Find the device according to the spec moniker
load_libusb()
context = usb1.USBContext()
@@ -540,7 +537,7 @@ async def open_usb_transport(spec: str) -> Transport:
except usb1.USBError:
logger.warning('failed to set configuration')
- source = UsbPacketSource(context, device, device_metadata, acl_in, events_in)
+ source = UsbPacketSource(device, device_metadata, acl_in, events_in)
sink = UsbPacketSink(device, acl_out)
return UsbTransport(context, device, interface, setting, source, sink)
except usb1.USBError as error:
diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml
index 84e25153..6590d124 100644
--- a/docs/mkdocs/mkdocs.yml
+++ b/docs/mkdocs/mkdocs.yml
@@ -70,6 +70,7 @@ nav:
- Extras:
- extras/index.md
- Android Remote HCI: extras/android_remote_hci.md
+ - Android BT Bench: extras/android_bt_bench.md
- Hive:
- hive/index.md
- Speaker: hive/web/speaker/speaker.html
diff --git a/docs/mkdocs/src/extras/android_bt_bench.md b/docs/mkdocs/src/extras/android_bt_bench.md
new file mode 100644
index 00000000..2417e00a
--- /dev/null
+++ b/docs/mkdocs/src/extras/android_bt_bench.md
@@ -0,0 +1,64 @@
+ANDROID BENCH APP
+=================
+
+This Android app that is compatible with the Bumble `bench` command line app.
+This app can be used to test the throughput and latency between two Android
+devices, or between an Android device and another device running the Bumble
+`bench` app.
+Only the RFComm Client, RFComm Server, L2CAP Client and L2CAP Server modes are
+supported.
+
+Building
+--------
+
+You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `BtBench` top level directory.
+You can also build with Android Studio: open the `BtBench` project. You can build and/or debug from there.
+
+If the build succeeds, you can find the app APKs (debug and release) at:
+
+ * [Release] ``app/build/outputs/apk/release/app-release-unsigned.apk``
+ * [Debug] ``app/build/outputs/apk/debug/app-debug.apk``
+
+
+Running
+-------
+
+### Starting the app
+You can start the app from the Android launcher, from Android Studio, or with `adb`
+
+#### Launching from the launcher
+Just tap the app icon on the launcher, check the parameters, and tap
+one of the benchmark action buttons.
+
+#### Launching with `adb`
+Using the `am` command, you can start the activity, and pass it arguments so that you can
+automatically start the benchmark test, and/or set the parameters.
+
+| Parameter Name | Parameter Type | Description
+|------------------------|----------------|------------
+| autostart | String | Benchmark to start. (rfcomm-client, rfcomm-server, l2cap-client or l2cap-server)
+| packet-count | Integer | Number of packets to send (rfcomm-client and l2cap-client only)
+| packet-size | Integer | Number of bytes per packet (rfcomm-client and l2cap-client only)
+| peer-bluetooth-address | Integer | Peer Bluetooth address to connect to (rfcomm-client and l2cap-client | only)
+
+
+!!! tip "Launching from adb with auto-start"
+ In this example, we auto-start the Rfcomm Server bench action.
+ ```bash
+ $ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-server
+ ```
+
+!!! tip "Launching from adb with auto-start and some parameters"
+ In this example, we auto-start the Rfcomm Client bench action, set the packet count to 100,
+ and the packet size to 1024, and connect to DA:4C:10:DE:17:02
+ ```bash
+ $ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-client --ei packet-count 100 --ei packet-size 1024 --es peer-bluetooth-address DA:4C:10:DE:17:02
+ ```
+
+#### Selecting a Peer Bluetooth Address
+The app's main activity has a "Peer Bluetooth Address" setting where you can change the address.
+
+!!! note "Bluetooth Address for L2CAP vs RFComm"
+ For BLE (L2CAP mode), the address of a device typically changes regularly (it is randomized for privacy), whereas the Bluetooth Classic addresses will remain the same (RFComm mode).
+ If two devices are paired and bonded, then they will each "see" a non-changing address for each other even with BLE (Resolvable Private Address)
+
diff --git a/docs/mkdocs/src/extras/index.md b/docs/mkdocs/src/extras/index.md
index ae906c1b..59af8389 100644
--- a/docs/mkdocs/src/extras/index.md
+++ b/docs/mkdocs/src/extras/index.md
@@ -8,4 +8,12 @@ Android Remote HCI
Allows using an Android phone's built-in Bluetooth controller with a Bumble
stack running on a development machine.
-See [Android Remote HCI](android_remote_hci.md) for details.
\ No newline at end of file
+See [Android Remote HCI](android_remote_hci.md) for details.
+
+Android BT Bench
+----------------
+
+An Android app that is compatible with the Bumble `bench` command line app.
+This app can be used to test the throughput and latency between two Android
+devices, or between an Android device and another device running the Bumble
+`bench` app.
\ No newline at end of file
diff --git a/examples/run_a2dp_info.py b/examples/run_a2dp_info.py
index 2f21cfa7..3a35695c 100644
--- a/examples/run_a2dp_info.py
+++ b/examples/run_a2dp_info.py
@@ -53,10 +53,10 @@ def sdp_records():
# -----------------------------------------------------------------------------
# pylint: disable-next=too-many-nested-blocks
-async def find_a2dp_service(device, connection):
+async def find_a2dp_service(connection):
# Connect to the SDP Server
- sdp_client = SDP_Client(device)
- await sdp_client.connect(connection)
+ sdp_client = SDP_Client(connection)
+ await sdp_client.connect()
# Search for services with an Audio Sink service class
search_result = await sdp_client.search_attributes(
@@ -177,7 +177,7 @@ async def main():
print('*** Encryption on')
# Look for an A2DP service
- avdtp_version = await find_a2dp_service(device, connection)
+ avdtp_version = await find_a2dp_service(connection)
if not avdtp_version:
print(color('!!! no AVDTP service found'))
return
diff --git a/examples/run_a2dp_source.py b/examples/run_a2dp_source.py
index 69dc2d01..92812fe1 100644
--- a/examples/run_a2dp_source.py
+++ b/examples/run_a2dp_source.py
@@ -165,9 +165,7 @@ async def main():
print('*** Encryption on')
# Look for an A2DP service
- avdtp_version = await find_avdtp_service_with_connection(
- device, connection
- )
+ avdtp_version = await find_avdtp_service_with_connection(connection)
if not avdtp_version:
print(color('!!! no A2DP service found'))
return
diff --git a/examples/run_classic_connect.py b/examples/run_classic_connect.py
index 3ae6ed8a..0acaedd2 100644
--- a/examples/run_classic_connect.py
+++ b/examples/run_classic_connect.py
@@ -63,8 +63,8 @@ async def main():
print(f'=== Connected to {connection.peer_address}!')
# Connect to the SDP Server
- sdp_client = SDP_Client(device)
- await sdp_client.connect(connection)
+ sdp_client = SDP_Client(connection)
+ await sdp_client.connect()
# List all services in the root browse group
service_record_handles = await sdp_client.search_services(
diff --git a/examples/run_hfp_gateway.py b/examples/run_hfp_gateway.py
index 5cb3eb3d..c3b392da 100644
--- a/examples/run_hfp_gateway.py
+++ b/examples/run_hfp_gateway.py
@@ -49,8 +49,8 @@ logger = logging.getLogger(__name__)
# pylint: disable-next=too-many-nested-blocks
async def list_rfcomm_channels(device, connection):
# Connect to the SDP Server
- sdp_client = SDP_Client(device)
- await sdp_client.connect(connection)
+ sdp_client = SDP_Client(connection)
+ await sdp_client.connect()
# Search for services that support the Handsfree Profile
search_result = await sdp_client.search_attributes(
@@ -184,7 +184,7 @@ async def main():
# Create a client and start it
print('@@@ Starting to RFCOMM client...')
- rfcomm_client = rfcomm.Client(device, connection)
+ rfcomm_client = rfcomm.Client(connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
diff --git a/examples/run_hid_host.py b/examples/run_hid_host.py
index 7076bdde..89c11de5 100644
--- a/examples/run_hid_host.py
+++ b/examples/run_hid_host.py
@@ -22,12 +22,9 @@ import logging
from bumble.colors import color
-import bumble.core
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import (
- BT_L2CAP_PROTOCOL_ID,
- BT_HIDP_PROTOCOL_ID,
BT_HUMAN_INTERFACE_DEVICE_SERVICE,
BT_BR_EDR_TRANSPORT,
)
@@ -35,8 +32,6 @@ from bumble.hci import Address
from bumble.hid import Host, Message
from bumble.sdp import (
Client as SDP_Client,
- DataElement,
- ServiceAttribute,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
@@ -75,11 +70,11 @@ SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID = 0x0210
# -----------------------------------------------------------------------------
-async def get_hid_device_sdp_record(device, connection):
+async def get_hid_device_sdp_record(connection):
# Connect to the SDP Server
- sdp_client = SDP_Client(device)
- await sdp_client.connect(connection)
+ sdp_client = SDP_Client(connection)
+ await sdp_client.connect()
if sdp_client:
print(color('Connected to SDP Server', 'blue'))
else:
@@ -365,7 +360,7 @@ async def main():
await connection.encrypt()
print('*** Encryption on')
- await get_hid_device_sdp_record(device, connection)
+ await get_hid_device_sdp_record(connection)
async def menu():
diff --git a/examples/run_rfcomm_client.py b/examples/run_rfcomm_client.py
index 9a942787..39ee7763 100644
--- a/examples/run_rfcomm_client.py
+++ b/examples/run_rfcomm_client.py
@@ -42,10 +42,10 @@ from bumble.sdp import (
# -----------------------------------------------------------------------------
-async def list_rfcomm_channels(device, connection):
+async def list_rfcomm_channels(connection):
# Connect to the SDP Server
- sdp_client = SDP_Client(device)
- await sdp_client.connect(connection)
+ sdp_client = SDP_Client(connection)
+ await sdp_client.connect()
# Search for services with an L2CAP service attribute
search_result = await sdp_client.search_attributes(
@@ -194,7 +194,7 @@ async def main():
channel = sys.argv[4]
if channel == 'discover':
- await list_rfcomm_channels(device, connection)
+ await list_rfcomm_channels(connection)
return
# Request authentication
@@ -209,7 +209,7 @@ async def main():
# Create a client and start it
print('@@@ Starting RFCOMM client...')
- rfcomm_client = Client(device, connection)
+ rfcomm_client = Client(connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
diff --git a/extras/android/BtBench/.gitignore b/extras/android/BtBench/.gitignore
new file mode 100644
index 00000000..aa724b77
--- /dev/null
+++ b/extras/android/BtBench/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/extras/android/BtBench/app/.gitignore b/extras/android/BtBench/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/extras/android/BtBench/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/extras/android/BtBench/app/build.gradle.kts b/extras/android/BtBench/app/build.gradle.kts
new file mode 100644
index 00000000..ffde1976
--- /dev/null
+++ b/extras/android/BtBench/app/build.gradle.kts
@@ -0,0 +1,70 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.kotlinAndroid)
+}
+
+android {
+ namespace = "com.github.google.bumble.btbench"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.github.google.bumble.btbench"
+ minSdk = 30
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.1"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+
+ implementation(libs.core.ktx)
+ implementation(libs.lifecycle.runtime.ktx)
+ implementation(libs.activity.compose)
+ implementation(platform(libs.compose.bom))
+ implementation(libs.ui)
+ implementation(libs.ui.graphics)
+ implementation(libs.ui.tooling.preview)
+ implementation(libs.material3)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(libs.espresso.core)
+ androidTestImplementation(platform(libs.compose.bom))
+ androidTestImplementation(libs.ui.test.junit4)
+ debugImplementation(libs.ui.tooling)
+ debugImplementation(libs.ui.test.manifest)
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/proguard-rules.pro b/extras/android/BtBench/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/extras/android/BtBench/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/AndroidManifest.xml b/extras/android/BtBench/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a6b5d774
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/ic_launcher-playstore.png b/extras/android/BtBench/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 00000000..d27fdd27
Binary files /dev/null and b/extras/android/BtBench/app/src/main/ic_launcher-playstore.png differ
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt
new file mode 100644
index 00000000..7722bb84
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt
@@ -0,0 +1,35 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.l2cap-client")
+
+class L2capClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
+ @SuppressLint("MissingPermission")
+ fun run() {
+ viewModel.running = true
+ val remoteDevice = bluetoothAdapter.getRemoteDevice(viewModel.peerBluetoothAddress)
+ val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
+
+ val client = SocketClient(viewModel, socket)
+ client.run()
+ }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt
new file mode 100644
index 00000000..79c70045
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt
@@ -0,0 +1,62 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.le.AdvertiseCallback
+import android.bluetooth.le.AdvertiseData
+import android.bluetooth.le.AdvertiseSettings
+import android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
+import android.os.Build
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.l2cap-server")
+
+class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdapter: BluetoothAdapter) {
+ @SuppressLint("MissingPermission")
+ fun run() {
+ // Advertise to that the peer can find us and connect.
+ val callback = object: AdvertiseCallback() {
+ override fun onStartFailure(errorCode: Int) {
+ Log.warning("failed to start advertising: $errorCode")
+ }
+
+ override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
+ Log.info("advertising started: $settingsInEffect")
+ }
+ }
+ val advertiseSettingsBuilder = AdvertiseSettings.Builder()
+ .setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY)
+ .setConnectable(true)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ advertiseSettingsBuilder.setDiscoverable(true)
+ }
+ val advertiseSettings = advertiseSettingsBuilder.build()
+ val advertiseData = AdvertiseData.Builder().build()
+ val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
+ val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
+ advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback)
+
+ val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel()
+ viewModel.l2capPsm = serverSocket.psm
+ Log.info("psm = $serverSocket.psm")
+
+ val server = SocketServer(viewModel, serverSocket)
+ server.run({ advertiser.stopAdvertising(callback) })
+ }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt
new file mode 100644
index 00000000..314f7465
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt
@@ -0,0 +1,307 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.Divider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.content.ContextCompat
+import com.github.google.bumble.btbench.ui.theme.BTBenchTheme
+import java.util.logging.Logger
+
+private val Log = Logger.getLogger("bumble.main-activity")
+
+const val PEER_BLUETOOTH_ADDRESS_PREF_KEY = "peer_bluetooth_address"
+const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
+const val SENDER_PACKET_SIZE_PREF_KEY = "sender_packet_size"
+
+class MainActivity : ComponentActivity() {
+ private val appViewModel = AppViewModel()
+ private var bluetoothAdapter: BluetoothAdapter? = null
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ appViewModel.loadPreferences(getPreferences(Context.MODE_PRIVATE))
+ checkPermissions()
+ }
+
+ private fun checkPermissions() {
+ val neededPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ arrayOf(
+ Manifest.permission.BLUETOOTH_ADVERTISE,
+ Manifest.permission.BLUETOOTH_SCAN,
+ Manifest.permission.BLUETOOTH_CONNECT
+ )
+ } else {
+ arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
+ }
+ val missingPermissions = neededPermissions.filter {
+ ContextCompat.checkSelfPermission(baseContext, it) != PackageManager.PERMISSION_GRANTED
+ }
+
+ if (missingPermissions.isEmpty()) {
+ start()
+ return
+ }
+
+ val requestPermissionsLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ permissions.entries.forEach {
+ Log.info("permission: ${it.key} = ${it.value}")
+ }
+ val grantCount = permissions.count { it.value }
+ if (grantCount == neededPermissions.size) {
+ // We have all the permissions we need.
+ start()
+ } else {
+ Log.warning("not all permissions granted")
+ }
+ }
+
+ requestPermissionsLauncher.launch(missingPermissions.toTypedArray())
+ return
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun initBluetooth() {
+ val bluetoothManager = ContextCompat.getSystemService(this, BluetoothManager::class.java)
+ bluetoothAdapter = bluetoothManager?.adapter
+
+ if (bluetoothAdapter == null) {
+ Log.warning("no bluetooth adapter")
+ return
+ }
+
+ if (!bluetoothAdapter!!.isEnabled) {
+ Log.warning("bluetooth not enabled")
+ return
+ }
+ }
+
+ private fun start() {
+ initBluetooth()
+ setContent {
+ MainView(
+ appViewModel,
+ ::becomeDiscoverable,
+ ::runRfcommClient,
+ ::runRfcommServer,
+ ::runL2capClient,
+ ::runL2capServer
+ )
+ }
+
+ // Process intent parameters, if any.
+ intent.getStringExtra("peer-bluetooth-address")?.let {
+ appViewModel.peerBluetoothAddress = it
+ }
+ val packetCount = intent.getIntExtra("packet-count", 0)
+ if (packetCount > 0) {
+ appViewModel.senderPacketCount = packetCount
+ }
+ appViewModel.updateSenderPacketCountSlider()
+ val packetSize = intent.getIntExtra("packet-size", 0)
+ if (packetSize > 0) {
+ appViewModel.senderPacketSize = packetSize
+ }
+ appViewModel.updateSenderPacketSizeSlider()
+ intent.getStringExtra("autostart")?.let {
+ when (it) {
+ "rfcomm-client" -> runRfcommClient()
+ "rfcomm-server" -> runRfcommServer()
+ "l2cap-client" -> runL2capClient()
+ "l2cap-server" -> runL2capServer()
+ }
+ }
+ }
+
+ private fun runRfcommClient() {
+ val rfcommClient = bluetoothAdapter?.let { RfcommClient(appViewModel, it) }
+ rfcommClient?.run()
+ }
+
+ private fun runRfcommServer() {
+ val rfcommServer = bluetoothAdapter?.let { RfcommServer(appViewModel, it) }
+ rfcommServer?.run()
+ }
+
+ private fun runL2capClient() {
+ val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it) }
+ l2capClient?.run()
+ }
+
+ private fun runL2capServer() {
+ val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
+ l2capServer?.run()
+ }
+
+ @SuppressLint("MissingPermission")
+ fun becomeDiscoverable() {
+ val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
+ discoverableIntent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300)
+ startActivity(discoverableIntent)
+ }
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun MainView(
+ appViewModel: AppViewModel,
+ becomeDiscoverable: () -> Unit,
+ runRfcommClient: () -> Unit,
+ runRfcommServer: () -> Unit,
+ runL2capClient: () -> Unit,
+ runL2capServer: () -> Unit
+) {
+ BTBenchTheme {
+ // A surface container using the 'background' color from the theme
+ Surface(
+ modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ Text(
+ text = "Bumble Bench",
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+ Divider()
+ val keyboardController = LocalSoftwareKeyboardController.current
+ TextField(label = {
+ Text(text = "Peer Bluetooth Address")
+ },
+ value = appViewModel.peerBluetoothAddress,
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
+ ),
+ onValueChange = {
+ appViewModel.updatePeerBluetoothAddress(it)
+ },
+ keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
+ )
+ Divider()
+ TextField(label = {
+ Text(text = "L2CAP PSM")
+ },
+ value = appViewModel.l2capPsm.toString(),
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done
+ ),
+ onValueChange = {
+ if (it.isNotEmpty()) {
+ val psm = it.toIntOrNull()
+ if (psm != null) {
+ appViewModel.l2capPsm = psm
+ }
+ }
+ },
+ keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }))
+ Divider()
+ Slider(
+ value = appViewModel.senderPacketCountSlider, onValueChange = {
+ appViewModel.senderPacketCountSlider = it
+ appViewModel.updateSenderPacketCount()
+ }, steps = 4
+ )
+ Text(text = "Packet Count: " + appViewModel.senderPacketCount.toString())
+ Divider()
+ Slider(
+ value = appViewModel.senderPacketSizeSlider, onValueChange = {
+ appViewModel.senderPacketSizeSlider = it
+ appViewModel.updateSenderPacketSize()
+ }, steps = 4
+ )
+ Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
+ Divider()
+ ActionButton(
+ text = "Become Discoverable", onClick = becomeDiscoverable, true
+ )
+ Row() {
+ ActionButton(
+ text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
+ )
+ ActionButton(
+ text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
+ )
+ }
+ Row() {
+ ActionButton(
+ text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
+ )
+ ActionButton(
+ text = "L2CAP Server", onClick = runL2capServer, !appViewModel.running
+ )
+ }
+ Divider()
+ Text(
+ text = "Packets Sent: ${appViewModel.packetsSent}"
+ )
+ Text(
+ text = "Packets Received: ${appViewModel.packetsReceived}"
+ )
+ Text(
+ text = "Throughput: ${appViewModel.throughput}"
+ )
+ Divider()
+ ActionButton(
+ text = "Abort", onClick = appViewModel::abort, appViewModel.running
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
+ Button(onClick = onClick, enabled = enabled) {
+ Text(text = text)
+ }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt
new file mode 100644
index 00000000..93755e40
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt
@@ -0,0 +1,163 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.content.SharedPreferences
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import java.util.UUID
+
+val DEFAULT_RFCOMM_UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE")
+const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
+const val DEFAULT_SENDER_PACKET_COUNT = 100
+const val DEFAULT_SENDER_PACKET_SIZE = 1024
+
+class AppViewModel : ViewModel() {
+ private var preferences: SharedPreferences? = null
+ var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
+ var l2capPsm by mutableStateOf(0)
+ var senderPacketCountSlider by mutableFloatStateOf(0.0F)
+ var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
+ var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
+ var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
+ var packetsSent by mutableIntStateOf(0)
+ var packetsReceived by mutableIntStateOf(0)
+ var throughput by mutableIntStateOf(0)
+ var running by mutableStateOf(false)
+ var aborter: (() -> Unit)? = null
+
+ fun loadPreferences(preferences: SharedPreferences) {
+ this.preferences = preferences
+
+ val savedPeerBluetoothAddress = preferences.getString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, null)
+ if (savedPeerBluetoothAddress != null) {
+ peerBluetoothAddress = savedPeerBluetoothAddress
+ }
+
+ val savedSenderPacketCount = preferences.getInt(SENDER_PACKET_COUNT_PREF_KEY, 0)
+ if (savedSenderPacketCount != 0) {
+ senderPacketCount = savedSenderPacketCount
+ }
+ updateSenderPacketCountSlider()
+
+ val savedSenderPacketSize = preferences.getInt(SENDER_PACKET_SIZE_PREF_KEY, 0)
+ if (savedSenderPacketSize != 0) {
+ senderPacketSize = savedSenderPacketSize
+ }
+ updateSenderPacketSizeSlider()
+ }
+
+ fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
+ this.peerBluetoothAddress = peerBluetoothAddress
+
+ // Save the address to the preferences
+ with(preferences!!.edit()) {
+ putString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, peerBluetoothAddress)
+ apply()
+ }
+ }
+
+ fun updateSenderPacketCountSlider() {
+ if (senderPacketCount <= 10) {
+ senderPacketCountSlider = 0.0F
+ } else if (senderPacketCount <= 50) {
+ senderPacketCountSlider = 0.2F
+ } else if (senderPacketCount <= 100) {
+ senderPacketCountSlider = 0.4F
+ } else if (senderPacketCount <= 500) {
+ senderPacketCountSlider = 0.6F
+ } else if (senderPacketCount <= 1000) {
+ senderPacketCountSlider = 0.8F
+ } else {
+ senderPacketCountSlider = 1.0F
+ }
+
+ with(preferences!!.edit()) {
+ putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
+ apply()
+ }
+ }
+
+ fun updateSenderPacketCount() {
+ if (senderPacketCountSlider < 0.1F) {
+ senderPacketCount = 10
+ } else if (senderPacketCountSlider < 0.3F) {
+ senderPacketCount = 50
+ } else if (senderPacketCountSlider < 0.5F) {
+ senderPacketCount = 100
+ } else if (senderPacketCountSlider < 0.7F) {
+ senderPacketCount = 500
+ } else if (senderPacketCountSlider < 0.9F) {
+ senderPacketCount = 1000
+ } else {
+ senderPacketCount = 10000
+ }
+
+ with(preferences!!.edit()) {
+ putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
+ apply()
+ }
+ }
+
+ fun updateSenderPacketSizeSlider() {
+ if (senderPacketSize <= 1) {
+ senderPacketSizeSlider = 0.0F
+ } else if (senderPacketSize <= 256) {
+ senderPacketSizeSlider = 0.02F
+ } else if (senderPacketSize <= 512) {
+ senderPacketSizeSlider = 0.4F
+ } else if (senderPacketSize <= 1024) {
+ senderPacketSizeSlider = 0.6F
+ } else if (senderPacketSize <= 2048) {
+ senderPacketSizeSlider = 0.8F
+ } else {
+ senderPacketSizeSlider = 1.0F
+ }
+
+ with(preferences!!.edit()) {
+ putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
+ apply()
+ }
+ }
+
+ fun updateSenderPacketSize() {
+ if (senderPacketSizeSlider < 0.1F) {
+ senderPacketSize = 1
+ } else if (senderPacketSizeSlider < 0.3F) {
+ senderPacketSize = 256
+ } else if (senderPacketSizeSlider < 0.5F) {
+ senderPacketSize = 512
+ } else if (senderPacketSizeSlider < 0.7F) {
+ senderPacketSize = 1024
+ } else if (senderPacketSizeSlider < 0.9F) {
+ senderPacketSize = 2048
+ } else {
+ senderPacketSize = 4096
+ }
+
+ with(preferences!!.edit()) {
+ putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
+ apply()
+ }
+ }
+
+ fun abort() {
+ aborter?.let { it() }
+ }
+}
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt
new file mode 100644
index 00000000..0fa85009
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt
@@ -0,0 +1,178 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.bluetooth.BluetoothSocket
+import java.io.IOException
+import java.nio.ByteBuffer
+import java.util.logging.Logger
+import kotlin.math.min
+
+private val Log = Logger.getLogger("btbench.packet")
+
+fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
+
+abstract class Packet(val type: Int, val payload: ByteArray = ByteArray(0)) {
+ companion object {
+ const val RESET = 0
+ const val SEQUENCE = 1
+ const val ACK = 2
+
+ const val LAST_FLAG = 1
+
+ fun from(data: ByteArray): Packet {
+ return when (data[0].toInt()) {
+ RESET -> ResetPacket()
+ SEQUENCE -> SequencePacket(
+ data[1].toInt(),
+ ByteBuffer.wrap(data, 2, 4).getInt(),
+ data.sliceArray(6.. AckPacket(data[1].toInt(), ByteBuffer.wrap(data, 2, 4).getInt())
+ else -> GenericPacket(data[0].toInt(), data.sliceArray(1.. onResetPacket()
+ is AckPacket -> onAckPacket()
+ is SequencePacket -> onSequencePacket(packet)
+ }
+ }
+
+ abstract fun onResetPacket()
+ abstract fun onAckPacket()
+ abstract fun onSequencePacket(packet: SequencePacket)
+}
+
+interface DataSink {
+ fun onData(data: ByteArray)
+}
+
+interface PacketIO {
+ var packetSink: PacketSink?
+ fun sendPacket(packet: Packet)
+}
+
+class StreamedPacketIO(private val dataSink: DataSink) : PacketIO {
+ private var bytesNeeded: Int = 0
+ private var rxPacket: ByteBuffer? = null
+ private var rxHeader = ByteBuffer.allocate(2)
+
+ override var packetSink: PacketSink? = null
+
+ fun onData(data: ByteArray) {
+ var current = data
+ while (current.isNotEmpty()) {
+ if (bytesNeeded > 0) {
+ val chunk = current.sliceArray(0.. Unit
+) {
+ fun receive() {
+ val buffer = ByteArray(4096)
+ do {
+ try {
+ val bytesRead = socket.inputStream.read(buffer)
+ if (bytesRead <= 0) {
+ break
+ }
+ onData(buffer.sliceArray(0.. Unit) {
+ var aborted = false
+ viewModel.running = true
+
+ fun cleanup() {
+ serverSocket.close()
+ viewModel.running = false
+ onTerminate()
+ }
+
+ thread(name = "SocketServer") {
+ while (!aborted) {
+ viewModel.aborter = {
+ serverSocket.close()
+ }
+ Log.info("waiting for connection...")
+ val socket = try {
+ serverSocket.accept()
+ } catch (error: IOException) {
+ Log.warning("server socket closed")
+ cleanup()
+ return@thread
+ }
+ Log.info("got connection")
+
+ viewModel.aborter = {
+ aborted = true
+ socket.close()
+ }
+ viewModel.peerBluetoothAddress = socket.remoteDevice.address
+
+ val socketDataSink = SocketDataSink(socket)
+ val streamIO = StreamedPacketIO(socketDataSink)
+ val socketDataSource = SocketDataSource(socket, streamIO::onData)
+ val receiver = Receiver(viewModel, streamIO)
+ socketDataSource.receive()
+ socket.close()
+ }
+ cleanup()
+ }
+ }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Color.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Color.kt
new file mode 100644
index 00000000..2b538c86
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.github.google.bumble.btbench.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Theme.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Theme.kt
new file mode 100644
index 00000000..17515795
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Theme.kt
@@ -0,0 +1,63 @@
+package com.github.google.bumble.btbench.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun BTBenchTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true, content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = colorScheme.primary.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme, typography = Typography, content = content
+ )
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Type.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Type.kt
new file mode 100644
index 00000000..029f8984
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Type.kt
@@ -0,0 +1,33 @@
+package com.github.google.bumble.btbench.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )/* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_background.xml b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..ca3826a4
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_foreground.xml b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..2b068d11
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..036d09bc
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..036d09bc
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..7dc41353
Binary files /dev/null and b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..37c0b563
Binary files /dev/null and b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..e8f5332b
Binary files /dev/null and b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..ac1ae9b0
Binary files /dev/null and b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..5e12fc69
Binary files /dev/null and b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..19ac4bfd
Binary files /dev/null and b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..30516ad4
Binary files /dev/null and b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..7a39c13c
Binary files /dev/null and b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..a2b1c8be
Binary files /dev/null and b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..2bbc83fe
Binary files /dev/null and b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/extras/android/BtBench/app/src/main/res/values/colors.xml b/extras/android/BtBench/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..f8c6127d
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/values/ic_launcher_background.xml b/extras/android/BtBench/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 00000000..c5d5899f
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/values/strings.xml b/extras/android/BtBench/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..018c3f97
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ BT Bench
+
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/values/themes.xml b/extras/android/BtBench/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..f0d08db3
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/xml/backup_rules.xml b/extras/android/BtBench/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..fa0f996d
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/xml/data_extraction_rules.xml b/extras/android/BtBench/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..9ee9997b
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/android/BtBench/build.gradle.kts b/extras/android/BtBench/build.gradle.kts
new file mode 100644
index 00000000..20d87a70
--- /dev/null
+++ b/extras/android/BtBench/build.gradle.kts
@@ -0,0 +1,7 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.kotlinAndroid) apply false
+}
+true // Needed to make the Suppress annotation work for the plugins block
\ No newline at end of file
diff --git a/extras/android/BtBench/gradle.properties b/extras/android/BtBench/gradle.properties
new file mode 100644
index 00000000..3c5031eb
--- /dev/null
+++ b/extras/android/BtBench/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/extras/android/BtBench/gradle/libs.versions.toml b/extras/android/BtBench/gradle/libs.versions.toml
new file mode 100644
index 00000000..03d3e584
--- /dev/null
+++ b/extras/android/BtBench/gradle/libs.versions.toml
@@ -0,0 +1,31 @@
+[versions]
+agp = "8.3.0-alpha11"
+kotlin = "1.9.0"
+core-ktx = "1.12.0"
+junit = "4.13.2"
+androidx-test-ext-junit = "1.1.5"
+espresso-core = "3.5.1"
+lifecycle-runtime-ktx = "2.6.2"
+activity-compose = "1.7.2"
+compose-bom = "2023.08.00"
+
+[libraries]
+core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
+espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
+lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" }
+activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
+compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
+ui = { group = "androidx.compose.ui", name = "ui" }
+ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+material3 = { group = "androidx.compose.material3", name = "material3" }
+
+[plugins]
+androidApplication = { id = "com.android.application", version.ref = "agp" }
+kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+
diff --git a/extras/android/BtBench/gradle/wrapper/gradle-wrapper.jar b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..e708b1c0
Binary files /dev/null and b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/extras/android/BtBench/gradle/wrapper/gradle-wrapper.properties b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..8ef5972c
--- /dev/null
+++ b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Wed Oct 25 07:40:52 PDT 2023
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/extras/android/BtBench/gradlew b/extras/android/BtBench/gradlew
new file mode 100755
index 00000000..4f906e0c
--- /dev/null
+++ b/extras/android/BtBench/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/extras/android/BtBench/gradlew.bat b/extras/android/BtBench/gradlew.bat
new file mode 100644
index 00000000..ac1b06f9
--- /dev/null
+++ b/extras/android/BtBench/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/extras/android/BtBench/settings.gradle.kts b/extras/android/BtBench/settings.gradle.kts
new file mode 100644
index 00000000..9bdd1ab4
--- /dev/null
+++ b/extras/android/BtBench/settings.gradle.kts
@@ -0,0 +1,24 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "BT Bench"
+include(":app")
+
\ No newline at end of file
diff --git a/tests/hfp_test.py b/tests/hfp_test.py
index 481d0b74..ed7e0df3 100644
--- a/tests/hfp_test.py
+++ b/tests/hfp_test.py
@@ -43,12 +43,10 @@ async def make_hfp_connections(
# Setup RFCOMM channel
wait_dlc = asyncio.get_running_loop().create_future()
- rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(
- lambda dlc: wait_dlc.set_result(dlc)
- )
+ rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(wait_dlc.set_result)
assert devices.connections[0]
assert devices.connections[1]
- client_mux = await rfcomm.Client(devices.devices[1], devices.connections[1]).start()
+ client_mux = await rfcomm.Client(devices.connections[1]).start()
client_dlc = await client_mux.open_dlc(rfcomm_channel)
server_dlc = await wait_dlc
diff --git a/tests/sdp_test.py b/tests/sdp_test.py
index 29db8751..ea8e0ab8 100644
--- a/tests/sdp_test.py
+++ b/tests/sdp_test.py
@@ -215,8 +215,8 @@ async def test_service_search():
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
- client = Client(devices.devices[1])
- await client.connect(devices.connections[1])
+ client = Client(devices.connections[1])
+ await client.connect()
services = await client.search_services(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
)
@@ -236,8 +236,8 @@ async def test_service_attribute():
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
- client = Client(devices.devices[1])
- await client.connect(devices.connections[1])
+ client = Client(devices.connections[1])
+ await client.connect()
attributes = await client.get_attributes(
0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
)
@@ -257,8 +257,8 @@ async def test_service_search_attribute():
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
- client = Client(devices.devices[1])
- await client.connect(devices.connections[1])
+ client = Client(devices.connections[1])
+ await client.connect()
attributes = await client.search_attributes(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0x0000FFFF, 8)]
)