diff --git a/apps/bench.py b/apps/bench.py index 8b37883..de14eee 100644 --- a/apps/bench.py +++ b/apps/bench.py @@ -77,6 +77,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 +129,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 +175,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, @@ -224,7 +228,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 +368,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 +714,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,9 +747,10 @@ class L2capServer(StreamedPacketIO): # RfcommClient # ----------------------------------------------------------------------------- class RfcommClient(StreamedPacketIO): - def __init__(self, device): + def __init__(self, device, channel): super().__init__() self.device = device + self.channel = channel self.ready = asyncio.Event() async def on_connection(self, connection): @@ -757,10 +762,9 @@ class RfcommClient(StreamedPacketIO): rfcomm_mux = await rfcomm_client.start() print(color('*** Started', 'blue')) - channel = DEFAULT_RFCOMM_CHANNEL - print(color(f'### Opening session for channel {channel}...', 'yellow')) + print(color(f'### Opening session for channel {self.channel}...', 'yellow')) try: - rfcomm_session = await rfcomm_mux.open_dlc(channel) + rfcomm_session = await rfcomm_mux.open_dlc(self.channel) print(color('### Session open', 'yellow'), rfcomm_session) except bumble.core.ConnectionError as error: print(color(f'!!! Session open failed: {error}', 'red')) @@ -780,7 +784,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 +792,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 +829,9 @@ class Central(Connection.Listener): mode_factory, connection_interval, phy, + authenticate, + encrypt, + extended_data_length, ): super().__init__() self.transport = transport @@ -832,6 +839,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 +914,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 +948,8 @@ class Central(Connection.Listener): ) ) + await mode.on_connection(self.connection) + await role.run() await asyncio.sleep(DEFAULT_LINGER_TIME) @@ -943,9 +974,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 +1040,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 +1081,16 @@ 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']) if mode == 'rfcomm-server': - return RfcommServer(device) + return RfcommServer(device, channel=ctx.obj['rfcomm_channel']) raise ValueError('invalid mode') @@ -1113,6 +1156,22 @@ 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( + '--l2cap-psm', + type=int, + default=DEFAULT_L2CAP_PSM, + help='L2CAP PSM to use', +) @click.option( '--packet-size', '-s', @@ -1139,17 +1198,34 @@ 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, + 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['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 +1246,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 +1266,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 +1282,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 5be4f3d..1e02a32 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/apps/pair.py b/apps/pair.py index 996b01c..884882a 100644 --- a/apps/pair.py +++ b/apps/pair.py @@ -28,12 +28,16 @@ from bumble.pairing import OobData, PairingDelegate, PairingConfig from bumble.smp import OobContext, OobLegacyContext from bumble.smp import error_name as smp_error_name from bumble.keys import JsonKeyStore +<<<<<<< HEAD from bumble.core import ( AdvertisingData, ProtocolError, BT_LE_TRANSPORT, BT_BR_EDR_TRANSPORT, ) +======= +from bumble.core import ProtocolError, BT_LE_TRANSPORT, BT_BR_EDR_TRANSPORT +>>>>>>> e3de14f (first implementation (+1 squashed commit)) from bumble.gatt import ( GATT_DEVICE_NAME_CHARACTERISTIC, GATT_GENERIC_ACCESS_SERVICE, @@ -394,9 +398,14 @@ async def pair( print(color(f'=== Connecting to {address_or_name}...', 'green')) connection = await device.connect( address_or_name, +<<<<<<< HEAD transport=BT_LE_TRANSPORT if mode == 'le' else BT_BR_EDR_TRANSPORT, ) pairing_failure = False +======= + transport=(BT_LE_TRANSPORT if mode == 'le' else BT_BR_EDR_TRANSPORT), + ) +>>>>>>> e3de14f (first implementation (+1 squashed commit)) if not request: try: diff --git a/bumble/device.py b/bumble/device.py index 45e919d..f343a4b 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/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml index 84e2515..6590d12 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 0000000..2417e00 --- /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 ae906c1..59af838 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/extras/android/BtBench/.gitignore b/extras/android/BtBench/.gitignore new file mode 100644 index 0000000..aa724b7 --- /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 0000000..42afabf --- /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 0000000..ffde197 --- /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 0000000..481bb43 --- /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 0000000..a6b5d77 --- /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 0000000..d27fdd2 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 0000000..7722bb8 --- /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 0000000..79c7004 --- /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 0000000..314f746 --- /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 0000000..3a62519 --- /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("0AF17D61-5DAE-4530-BE0D-6A8D54C3608B") +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.2) { + senderPacketCount = 10 + } else if (senderPacketCountSlider < 0.4) { + senderPacketCount = 50 + } else if (senderPacketCountSlider < 0.6) { + senderPacketCount = 100 + } else if (senderPacketCountSlider < 0.8) { + senderPacketCount = 500 + } else if (senderPacketCountSlider < 1.0) { + 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.2) { + senderPacketSize = 1 + } else if (senderPacketSizeSlider < 0.4) { + senderPacketSize = 256 + } else if (senderPacketSizeSlider < 0.6) { + senderPacketSize = 512 + } else if (senderPacketSizeSlider < 0.8) { + senderPacketSize = 1024 + } else if (senderPacketSizeSlider < 1.0) { + 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 0000000..0fa8500 --- /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 0000000..2b538c8 --- /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 0000000..1751579 --- /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 0000000..029f898 --- /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 0000000..ca3826a --- /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 0000000..2b068d1 --- /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 0000000..036d09b --- /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 0000000..036d09b --- /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 0000000..7dc4135 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 0000000..37c0b56 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 0000000..e8f5332 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 0000000..ac1ae9b 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 0000000..5e12fc6 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 0000000..19ac4bf 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 0000000..30516ad 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 0000000..7a39c13 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 0000000..a2b1c8b 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 0000000..2bbc83f 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 0000000..f8c6127 --- /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 0000000..c5d5899 --- /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 0000000..018c3f9 --- /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 0000000..f0d08db --- /dev/null +++ b/extras/android/BtBench/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +