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 @@ + + + +