From d08ca79b89e636a027ee0e166615b7d69f873c7e Mon Sep 17 00:00:00 2001 From: Edwin Wu Date: Thu, 16 Oct 2025 19:16:17 -0700 Subject: [PATCH] [NC Apple coverage] Add unit tests for Nearby Apple's BleMedium. PiperOrigin-RevId: 820484319 --- internal/platform/implementation/apple/BUILD | 1 + .../platform/implementation/apple/GNCUtils.h | 3 + .../platform/implementation/apple/GNCUtils.m | 16 + .../apple/Mediums/BLE/Tests/BUILD | 4 + .../Mediums/BLE/Tests/GNCFakeBLEGATTServer.h | 29 + .../Mediums/BLE/Tests/GNCFakeBLEGATTServer.m | 30 + .../Mediums/BLE/Tests/GNCFakeBLEMedium.h | 50 ++ .../Mediums/BLE/Tests/GNCFakeBLEMedium.m | 128 +++++ .../platform/implementation/apple/Tests/BUILD | 1 + .../implementation/apple/Tests/GNCUtilsTest.m | 18 + .../apple/Tests/ble_medium_test.mm | 525 ++++++++++++++++++ .../implementation/apple/ble_medium.h | 2 + .../implementation/apple/ble_medium.mm | 44 +- 13 files changed, 831 insertions(+), 20 deletions(-) create mode 100644 internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEGATTServer.h create mode 100644 internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEGATTServer.m create mode 100644 internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEMedium.h create mode 100644 internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEMedium.m create mode 100644 internal/platform/implementation/apple/Tests/ble_medium_test.mm diff --git a/internal/platform/implementation/apple/BUILD b/internal/platform/implementation/apple/BUILD index 54444bb1..6050c2ff 100644 --- a/internal/platform/implementation/apple/BUILD +++ b/internal/platform/implementation/apple/BUILD @@ -145,6 +145,7 @@ objc_library( # Prevent Objective-C++ headers from being pulled into swift. aspect_hints = ["//tools/build_defs/swift:no_module"], deps = [ + ":Shared", ":bluetooth_adapter_v2", ":comm", "//internal/platform:base", diff --git a/internal/platform/implementation/apple/GNCUtils.h b/internal/platform/implementation/apple/GNCUtils.h index c1f2b232..7561b0f0 100644 --- a/internal/platform/implementation/apple/GNCUtils.h +++ b/internal/platform/implementation/apple/GNCUtils.h @@ -35,6 +35,9 @@ NSData *_Nullable GNCMd5Data(NSData *data); /// Generates an MD5 hash (16 bytes) from a string. NSData *_Nullable GNCMd5String(NSString *string); +/// Converts NSData to a hex string with a "0x" prefix. +NSString *GNCConvertDataToHexString(NSData *_Nullable data); + #ifdef __cplusplus } // extern "C" #endif diff --git a/internal/platform/implementation/apple/GNCUtils.m b/internal/platform/implementation/apple/GNCUtils.m index e773db3f..1d8ba75d 100644 --- a/internal/platform/implementation/apple/GNCUtils.m +++ b/internal/platform/implementation/apple/GNCUtils.m @@ -41,4 +41,20 @@ NSData *GNCMd5String(NSString *string) { return GNCMd5Data([string dataUsingEncoding:NSUTF8StringEncoding]); } +NSString *GNCConvertDataToHexString(NSData *_Nullable data) { + NSUInteger dataLength = data.length; + if (dataLength == 0) { + return @"0x"; + } + + const unsigned char *dataBuffer = (const unsigned char *)data.bytes; + NSMutableString *hexString = [NSMutableString stringWithCapacity:dataLength * 2]; + + for (NSUInteger i = 0; i < dataLength; ++i) { + [hexString appendFormat:@"%02lx", (unsigned long)dataBuffer[i]]; + } + + return [NSString stringWithFormat:@"0x%@", hexString]; +} + NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Mediums/BLE/Tests/BUILD b/internal/platform/implementation/apple/Mediums/BLE/Tests/BUILD index 675c2d1a..d7b0e86e 100644 --- a/internal/platform/implementation/apple/Mediums/BLE/Tests/BUILD +++ b/internal/platform/implementation/apple/Mediums/BLE/Tests/BUILD @@ -33,6 +33,8 @@ objc_library( "GNCBLEL2CAPServerTest.m", "GNCBLEL2CAPStreamTest.m", "GNCBLEMediumTest.m", + "GNCFakeBLEGATTServer.m", + "GNCFakeBLEMedium.m", "GNCFakeCentralManager.m", "GNCFakePeripheral.m", "GNCFakePeripheralManager.m", @@ -52,6 +54,8 @@ objc_library( "GNCBLEL2CAPClient+Testing.h", "GNCBLEL2CAPFakeInputOutputStream.h", "GNCBLEMedium+Testing.h", + "GNCFakeBLEGATTServer.h", + "GNCFakeBLEMedium.h", "GNCFakeCentralManager.h", "GNCFakePeripheral.h", "GNCFakePeripheralManager.h", diff --git a/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEGATTServer.h b/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEGATTServer.h new file mode 100644 index 00000000..67465f86 --- /dev/null +++ b/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEGATTServer.h @@ -0,0 +1,29 @@ +// Copyright 2025 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. + +#import "internal/platform/implementation/apple/Mediums/BLE/GNCBLEGATTServer.h" + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GNCFakeBLEGATTServer : GNCBLEGATTServer + +// Add properties to control fake behavior if needed. +@property(nonatomic, nullable) NSError *stopError; +@property(nonatomic) BOOL isStopped; + +@end + +NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEGATTServer.m b/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEGATTServer.m new file mode 100644 index 00000000..7cabd2df --- /dev/null +++ b/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEGATTServer.m @@ -0,0 +1,30 @@ +// Copyright 2025 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. + +#import "internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEGATTServer.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation GNCFakeBLEGATTServer + +- (void)stopWithCompletionHandler:(nullable void (^)(NSError *_Nullable))completionHandler { + self.isStopped = YES; + if (completionHandler) { + completionHandler(self.stopError); + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEMedium.h b/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEMedium.h new file mode 100644 index 00000000..a8af868f --- /dev/null +++ b/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEMedium.h @@ -0,0 +1,50 @@ +// Copyright 2025 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. + +#import "internal/platform/implementation/apple/Mediums/BLE/GNCBLEMedium.h" + +#import +#import + +#import "internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEGATTServer.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface GNCFakeBLEMedium : GNCBLEMedium + +// Properties to control fake behavior. +@property(nonatomic, nullable) NSError *startAdvertisingError; +@property(nonatomic, nullable) NSError *stopAdvertisingError; +@property(nonatomic, nullable) NSError *startScanningError; +@property(nonatomic, nullable) NSError *stopScanningError; +@property(nonatomic, nullable) NSError *resumeScanningError; +@property(nonatomic, nullable) NSError *startGATTServerError; +@property(nonatomic, nullable) NSError *connectToGATTServerError; +@property(nonatomic, nullable) NSError *openServerSocketError; +@property(nonatomic, nullable) NSError *openL2CAPServerSocketError; +@property(nonatomic, nullable) NSError *openL2CAPChannelError; + +@property(nonatomic, nullable) GNCBLEGATTServer *fakeGATTServer; +@property(nonatomic, nullable) GNCBLEGATTClient *fakeGATTClient; +@property(nonatomic, nullable) GNCBLEL2CAPStream *fakeL2CAPStream; +@property(nonatomic) uint16_t fakePSM; + +@property(nonatomic, nullable) id lastConnectedPeripheral; +@property(nonatomic, nullable) GNCGATTDisconnectionHandler lastDisconnectionHandler; + +@property(nonatomic, nullable) GNCAdvertisementFoundHandler advertisementFoundHandler; + +@end + +NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEMedium.m b/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEMedium.m new file mode 100644 index 00000000..1a38817a --- /dev/null +++ b/internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEMedium.m @@ -0,0 +1,128 @@ +// Copyright 2025 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. + +#import "internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEMedium.h" + +#import +#import + +#import "internal/platform/implementation/apple/Mediums/BLE/GNCBLEGATTClient.h" +#import "internal/platform/implementation/apple/Mediums/BLE/GNCBLEGATTServer.h" +#import "internal/platform/implementation/apple/Mediums/BLE/GNCBLEL2CAPClient.h" +#import "internal/platform/implementation/apple/Mediums/BLE/GNCBLEL2CAPServer.h" +#import "internal/platform/implementation/apple/Mediums/BLE/GNCBLEMedium.h" +#import "internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakePeripheral.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation GNCFakeBLEMedium + +- (void)startAdvertisingData:(NSDictionary *)advertisementData + completionHandler:(nullable GNCStartAdvertisingCompletionHandler)completionHandler { + if (completionHandler) { + completionHandler(self.startAdvertisingError); + } +} + +- (void)stopAdvertisingWithCompletionHandler:(nullable GNCStopAdvertisingCompletionHandler)completionHandler { + if (completionHandler) { + completionHandler(self.stopAdvertisingError); + } +} + +- (void)startScanningForService:(CBUUID *)serviceUUID + advertisementFoundHandler:(GNCAdvertisementFoundHandler)advertisementFoundHandler + completionHandler:(nullable GNCStartScanningCompletionHandler)completionHandler { + self.advertisementFoundHandler = advertisementFoundHandler; + if (completionHandler) { + completionHandler(self.startScanningError); + } +} + +- (void)startScanningForMultipleServices:(NSArray *)serviceUUIDs + advertisementFoundHandler:(GNCAdvertisementFoundHandler)advertisementFoundHandler + completionHandler:(nullable GNCStartScanningCompletionHandler)completionHandler { + self.advertisementFoundHandler = advertisementFoundHandler; + if (completionHandler) { + completionHandler(self.startScanningError); + } +} + +- (void)stopScanningWithCompletionHandler:(nullable GNCStopScanningCompletionHandler)completionHandler { + if (completionHandler) { + completionHandler(self.stopScanningError); + } +} + +- (void)resumeMediumScanning:(nullable GNCStartScanningCompletionHandler)completionHandler { + if (completionHandler) { + completionHandler(self.resumeScanningError); + } +} + +- (void)startGATTServerWithCompletionHandler:(nullable GNCGATTServerCompletionHandler)completionHandler { + if (completionHandler) { + completionHandler(self.fakeGATTServer, self.startGATTServerError); + } +} + +- (void)connectToGATTServerForPeripheral:(id)peripheral + disconnectionHandler:(nullable GNCGATTDisconnectionHandler)disconnectionHandler + completionHandler:(nullable GNCGATTConnectionCompletionHandler)completionHandler { + self.lastConnectedPeripheral = peripheral; + self.lastDisconnectionHandler = disconnectionHandler; + if (completionHandler) { + if (!self.connectToGATTServerError) { + if (!self.fakeGATTClient) { + // Create a default fake client if one isn't provided. + self.fakeGATTClient = [[GNCBLEGATTClient alloc] initWithPeripheral:peripheral + requestDisconnectionHandler:^(id p) { + // Do nothing in fake. + }]; + } + completionHandler(self.fakeGATTClient, nil); + } else { + completionHandler(nil, self.connectToGATTServerError); + } + } +} + +- (void)openL2CAPServerWithPSMPublishedCompletionHandler: + (GNCOpenL2CAPServerPSMPublishedCompletionHandler)psmPublishedCompletionHandler + channelOpenedCompletionHandler: + (GNCOpenL2CAPServerChannelOpendCompletionHandler)channelOpenedCompletionHandler + peripheralManager: + (nullable id)peripheralManager { + if (psmPublishedCompletionHandler) { + psmPublishedCompletionHandler(self.fakePSM, self.openL2CAPServerSocketError); + } + // In the fake, we don't have a real channel opened event. +} + +- (void)openL2CAPChannelWithPSM:(CBL2CAPPSM)PSM + peripheral:(id)peripheral + completionHandler:(nullable GNCOpenL2CAPStreamCompletionHandler)completionHandler { + self.lastConnectedPeripheral = peripheral; + if (completionHandler) { + completionHandler(self.fakeL2CAPStream, self.openL2CAPChannelError); + } +} + +- (BOOL)supportsExtendedAdvertisements { + return NO; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Tests/BUILD b/internal/platform/implementation/apple/Tests/BUILD index 0beba109..9ee589fb 100644 --- a/internal/platform/implementation/apple/Tests/BUILD +++ b/internal/platform/implementation/apple/Tests/BUILD @@ -37,6 +37,7 @@ objc_library( "GNCUtilsTest.m", "GNCWifiLanMediumTest.mm", "UtilsTest.mm", + "ble_medium_test.mm", "ble_peripheral_test.mm", "ble_socket_test.mm", ], diff --git a/internal/platform/implementation/apple/Tests/GNCUtilsTest.m b/internal/platform/implementation/apple/Tests/GNCUtilsTest.m index 560921de..8daf666e 100644 --- a/internal/platform/implementation/apple/Tests/GNCUtilsTest.m +++ b/internal/platform/implementation/apple/Tests/GNCUtilsTest.m @@ -153,4 +153,22 @@ @"MD5 hash for an empty string did not match the expected value."); } +- (void)testConvertDataToHexString { + // Test with nil data. + XCTAssertEqualObjects(GNCConvertDataToHexString(nil), @"0x", + @"Hex string for nil data should be '0x'."); + + // Test with empty data. + NSData *emptyData = [NSData data]; + XCTAssertEqualObjects(GNCConvertDataToHexString(emptyData), @"0x", + @"Hex string for empty data should be '0x'."); + + // Test with sample data. + const unsigned char bytes[] = {0xDE, 0xAD, 0xBE, 0xEF}; + NSData *sampleData = [NSData dataWithBytes:bytes length:sizeof(bytes)]; + NSString *expectedHexString = @"0xdeadbeef"; + XCTAssertEqualObjects(GNCConvertDataToHexString(sampleData), expectedHexString, + @"Hex string for sample data did not match the expected value."); +} + @end diff --git a/internal/platform/implementation/apple/Tests/ble_medium_test.mm b/internal/platform/implementation/apple/Tests/ble_medium_test.mm new file mode 100644 index 00000000..f6012f87 --- /dev/null +++ b/internal/platform/implementation/apple/Tests/ble_medium_test.mm @@ -0,0 +1,525 @@ +// Copyright 2025 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. + +#import "internal/platform/implementation/apple/ble_medium.h" + +#import +#import + +#include +#include +#include +#include +#include + +#import "internal/platform/implementation/apple/Mediums/BLE/GNCBLEGATTClient.h" +#import "internal/platform/implementation/apple/Mediums/BLE/GNCBLEGATTServer.h" +#import "internal/platform/implementation/apple/Mediums/BLE/GNCBLEL2CAPClient.h" +#import "internal/platform/implementation/apple/Mediums/BLE/GNCBLEMedium.h" +#import "internal/platform/implementation/apple/Mediums/BLE/GNCPeripheral.h" +#import "internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEGATTServer.h" +#import "internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakeBLEMedium.h" +#import "internal/platform/implementation/apple/Mediums/BLE/Tests/GNCFakePeripheral.h" +#include "internal/platform/implementation/apple/ble_utils.h" +#include "internal/platform/implementation/ble.h" + +// TODO(b/293336684): Add tests for Weave sockets, AdvertisementFoundHandler, and more edge cases. + +static NSString *const kTestServiceUUIDString = @"0000FE2C-0000-1000-8000-00805F9B34FB"; +static const char *const kTestServiceID = "TestServiceID"; + +@interface GNCBLEMediumCPPTest : XCTestCase +@end + +@implementation GNCBLEMediumCPPTest { + GNCFakeBLEMedium *_fakeGNCBLEMedium; + std::unique_ptr _medium; +} + +- (void)setUp { + [super setUp]; + _fakeGNCBLEMedium = [[GNCFakeBLEMedium alloc] init]; + _medium = std::make_unique((GNCBLEMedium *)_fakeGNCBLEMedium); +} + +#pragma mark - Advertising Tests + +- (void)testStartAdvertising_Success { + nearby::api::ble::BleAdvertisementData advertising_data; + nearby::api::ble::AdvertiseParameters advertise_set_parameters; + + bool result = _medium->StartAdvertising(advertising_data, advertise_set_parameters); + + XCTAssertTrue(result); +} + +- (void)testStartAdvertising_Failure { + nearby::api::ble::BleAdvertisementData advertising_data; + nearby::api::ble::AdvertiseParameters advertise_set_parameters; + _fakeGNCBLEMedium.startAdvertisingError = [NSError errorWithDomain:@"test" code:0 userInfo:nil]; + + bool result = _medium->StartAdvertising(advertising_data, advertise_set_parameters); + + XCTAssertFalse(result); +} + +- (void)testStopAdvertising_Success { + bool result = _medium->StopAdvertising(); + + XCTAssertTrue(result); +} + +- (void)testStopAdvertising_Failure { + _fakeGNCBLEMedium.stopAdvertisingError = [NSError errorWithDomain:@"test" code:0 userInfo:nil]; + + bool result = _medium->StopAdvertising(); + + XCTAssertFalse(result); +} + +- (void)testStartAdvertisingAsync_Success { + nearby::api::ble::BleAdvertisementData advertising_data; + nearby::api::ble::AdvertiseParameters advertise_set_parameters; + XCTestExpectation *expectation = [self expectationWithDescription:@"Advertising started"]; + nearby::api::ble::BleMedium::AdvertisingCallback callback = { + .start_advertising_result = std::function(^(absl::Status status) { + XCTAssertTrue(status.ok()); + [expectation fulfill]; + }), + }; + + auto session = + _medium->StartAdvertising(advertising_data, advertise_set_parameters, std::move(callback)); + + XCTAssertNotEqual(session.get(), nullptr); + [self waitForExpectations:@[ expectation ] timeout:1.0]; +} + +- (void)testStartAdvertisingAsync_Failure { + nearby::api::ble::BleAdvertisementData advertising_data; + nearby::api::ble::AdvertiseParameters advertise_set_parameters; + _fakeGNCBLEMedium.startAdvertisingError = [NSError errorWithDomain:@"test" code:0 userInfo:nil]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Advertising failed to start"]; + nearby::api::ble::BleMedium::AdvertisingCallback callback = { + .start_advertising_result = std::function(^(absl::Status status) { + XCTAssertFalse(status.ok()); + [expectation fulfill]; + }), + }; + + auto session = + _medium->StartAdvertising(advertising_data, advertise_set_parameters, std::move(callback)); + + XCTAssertNotEqual(session.get(), nullptr); + [self waitForExpectations:@[ expectation ] timeout:1.0]; +} + +#pragma mark - Scanning Tests + +- (void)testStartScanning_Success { + nearby::Uuid service_uuid(0, 0); + nearby::api::ble::TxPowerLevel tx_power_level = nearby::api::ble::TxPowerLevel::kUltraLow; + + auto session = _medium->StartScanning(service_uuid, tx_power_level, + nearby::api::ble::BleMedium::ScanningCallback{}); + + XCTAssertNotEqual(session.get(), nullptr); +} + +- (void)testStartScanning_Failure { + nearby::Uuid service_uuid(0, 0); + nearby::api::ble::TxPowerLevel tx_power_level = nearby::api::ble::TxPowerLevel::kUltraLow; + _fakeGNCBLEMedium.startScanningError = [NSError errorWithDomain:@"test" code:0 userInfo:nil]; + + auto session = _medium->StartScanning(service_uuid, tx_power_level, + nearby::api::ble::BleMedium::ScanningCallback{}); + + XCTAssertEqual(session.get(), nullptr); +} + +- (void)testStartMultipleServicesScanning_Success { + std::vector service_uuids = {nearby::Uuid(0, 0)}; + nearby::api::ble::TxPowerLevel tx_power_level = nearby::api::ble::TxPowerLevel::kUltraLow; + + bool result = _medium->StartMultipleServicesScanning(service_uuids, tx_power_level, {}); + + XCTAssertTrue(result); +} + +- (void)testStartMultipleServicesScanning_Failure { + std::vector service_uuids = {nearby::Uuid(0, 0)}; + nearby::api::ble::TxPowerLevel tx_power_level = nearby::api::ble::TxPowerLevel::kUltraLow; + _fakeGNCBLEMedium.startScanningError = [NSError errorWithDomain:@"test" code:0 userInfo:nil]; + + bool result = _medium->StartMultipleServicesScanning(service_uuids, tx_power_level, {}); + + XCTAssertFalse(result); +} + +- (void)testStopScanning_Success { + bool result = _medium->StopScanning(); + + XCTAssertTrue(result); +} + +- (void)testStopScanning_Failure { + _fakeGNCBLEMedium.stopScanningError = [NSError errorWithDomain:@"test" code:0 userInfo:nil]; + + bool result = _medium->StopScanning(); + + XCTAssertFalse(result); +} + +- (void)testPauseResumeScanning_Success { + XCTAssertTrue(_medium->PauseMediumScanning()); + XCTAssertTrue(_medium->ResumeMediumScanning()); +} + +- (void)testPauseResumeScanning_Failure { + _fakeGNCBLEMedium.stopScanningError = [NSError errorWithDomain:@"test" code:1 userInfo:nil]; + XCTAssertFalse(_medium->PauseMediumScanning()); + + _fakeGNCBLEMedium.resumeScanningError = [NSError errorWithDomain:@"test" code:2 userInfo:nil]; + XCTAssertFalse(_medium->ResumeMediumScanning()); +} + +// TODO(b/293336684): Add tests for async StartScanning. + +#pragma mark - GATT Server Tests + +- (void)testStartGattServer_Success { + _fakeGNCBLEMedium.fakeGATTServer = [[GNCFakeBLEGATTServer alloc] init]; + + auto gatt_server = _medium->StartGattServer({}); + + XCTAssertNotEqual(gatt_server.get(), nullptr); +} + +- (void)testStartGattServer_Failure { + _fakeGNCBLEMedium.startGATTServerError = [NSError errorWithDomain:@"test" code:0 userInfo:nil]; + + auto gatt_server = _medium->StartGattServer({}); + + XCTAssertEqual(gatt_server.get(), nullptr); +} + +#pragma mark - GATT Client Tests + +- (void)testConnectToGattServer_PeripheralNotFound { + auto gatt_client = + _medium->ConnectToGattServer(99999, nearby::api::ble::TxPowerLevel::kUltraLow, {}); + + XCTAssertEqual(gatt_client.get(), nullptr); +} + +- (void)testConnectToGattServer_Success { + GNCFakePeripheral *fakePeripheral = [[GNCFakePeripheral alloc] init]; + NSDictionary *serviceData = + @{[CBUUID UUIDWithString:kTestServiceUUIDString] : [NSData dataWithBytes:"test" length:4]}; + XCTestExpectation *expectation = + [self expectationWithDescription:@"Advertisement found callback should be called."]; + nearby::api::ble::BleMedium::ScanCallback callback = { + .advertisement_found_cb = std::function( + [expectation](nearby::api::ble::BlePeripheral::UniqueId peripheral_id, + const nearby::api::ble::BleAdvertisementData &advertisement) { + [expectation fulfill]; + })}; + _medium->StartScanning(nearby::Uuid(0, 0), nearby::api::ble::TxPowerLevel::kUltraLow, + std::move(callback)); + if (_fakeGNCBLEMedium.advertisementFoundHandler) { + _fakeGNCBLEMedium.advertisementFoundHandler(fakePeripheral, serviceData); + } + [self waitForExpectations:@[ expectation ] timeout:1.0]; + + auto gatt_client = _medium->ConnectToGattServer(fakePeripheral.identifier.hash, + nearby::api::ble::TxPowerLevel::kUltraLow, {}); + + XCTAssertNotEqual(gatt_client.get(), nullptr); +} + +- (void)testConnectToGattServer_Failure { + GNCFakePeripheral *fakePeripheral = [[GNCFakePeripheral alloc] init]; + NSDictionary *serviceData = + @{[CBUUID UUIDWithString:kTestServiceUUIDString] : [NSData dataWithBytes:"test" length:4]}; + XCTestExpectation *expectation = + [self expectationWithDescription:@"Advertisement found callback should be called."]; + nearby::api::ble::BleMedium::ScanCallback callback = { + .advertisement_found_cb = std::function( + [expectation](nearby::api::ble::BlePeripheral::UniqueId peripheral_id, + const nearby::api::ble::BleAdvertisementData &advertisement) { + [expectation fulfill]; + })}; + _medium->StartScanning(nearby::Uuid(0, 0), nearby::api::ble::TxPowerLevel::kUltraLow, + std::move(callback)); + if (_fakeGNCBLEMedium.advertisementFoundHandler) { + _fakeGNCBLEMedium.advertisementFoundHandler(fakePeripheral, serviceData); + } + [self waitForExpectations:@[ expectation ] timeout:1.0]; + _fakeGNCBLEMedium.connectToGATTServerError = [NSError errorWithDomain:@"test" + code:0 + userInfo:nil]; + + auto gatt_client = _medium->ConnectToGattServer(fakePeripheral.identifier.hash, + nearby::api::ble::TxPowerLevel::kUltraLow, {}); + + XCTAssertEqual(gatt_client.get(), nullptr); +} + +#pragma mark - L2CAP Server Tests + +- (void)testOpenL2capServerSocket_Success { + _fakeGNCBLEMedium.fakePSM = 123; + + auto l2cap_server = _medium->OpenL2capServerSocket(kTestServiceID); + + XCTAssertNotEqual(l2cap_server.get(), nullptr); + // XCTAssertEqual(l2cap_server->GetPSM(), 123); // Needs friend class or accessor +} + +- (void)testOpenL2capServerSocket_Failure { + _fakeGNCBLEMedium.openL2CAPServerSocketError = [NSError errorWithDomain:@"test" + code:0 + userInfo:nil]; + + auto l2cap_server = _medium->OpenL2capServerSocket(kTestServiceID); + + XCTAssertEqual(l2cap_server.get(), nullptr); +} + +#pragma mark - L2CAP Client Tests + +- (void)testConnectOverL2cap_NotFound { + auto l2cap_socket = _medium->ConnectOverL2cap( + 123, kTestServiceID, nearby::api::ble::TxPowerLevel::kUltraLow, 99999, nullptr); + + XCTAssertEqual(l2cap_socket.get(), nullptr); +} + +#pragma mark - Server Socket Tests + +- (void)testOpenServerSocket_Success { + XCTSkip(@"TODO(b/293336684): Requires more capable GNSPeripheralManager fakes for full testing."); + auto server_socket = _medium->OpenServerSocket(kTestServiceID); + + XCTAssertNotEqual(server_socket.get(), nullptr); +} + +// TODO(b/293336684): Add failure test case for OpenServerSocket when GNSPeripheralManager fakes +// are more capable. + +#pragma mark - Other Tests + +- (void)testIsExtendedAdvertisementsAvailable { + XCTAssertFalse(_medium->IsExtendedAdvertisementsAvailable()); +} + +- (void)testRetrieveBlePeripheralIdFromNativeId { + GNCFakePeripheral *fakePeripheral = [[GNCFakePeripheral alloc] init]; + NSDictionary *serviceData = + @{[CBUUID UUIDWithString:kTestServiceUUIDString] : [NSData dataWithBytes:"test" length:4]}; + XCTestExpectation *expectation = + [self expectationWithDescription:@"Advertisement found callback should be called."]; + nearby::api::ble::BleMedium::ScanCallback callback = { + .advertisement_found_cb = std::function( + [expectation](nearby::api::ble::BlePeripheral::UniqueId peripheral_id, + const nearby::api::ble::BleAdvertisementData &advertisement) { + [expectation fulfill]; + })}; + _medium->StartScanning(nearby::Uuid(0, 0), nearby::api::ble::TxPowerLevel::kUltraLow, + std::move(callback)); + if (_fakeGNCBLEMedium.advertisementFoundHandler) { + _fakeGNCBLEMedium.advertisementFoundHandler(fakePeripheral, serviceData); + } + [self waitForExpectations:@[ expectation ] timeout:1.0]; + + // Test with a known UUID + std::optional result1 = + _medium->RetrieveBlePeripheralIdFromNativeId( + [fakePeripheral.identifier.UUIDString UTF8String]); + XCTAssertTrue(result1.has_value()); + XCTAssertEqual(result1.value(), fakePeripheral.identifier.hash); + + // Test with an unknown but valid UUID + NSString *unknownUUIDString = [[NSUUID alloc] init].UUIDString; + std::optional result2 = + _medium->RetrieveBlePeripheralIdFromNativeId([unknownUUIDString UTF8String]); + XCTAssertTrue(result2.has_value()); // Valid UUID format should return an ID. + XCTAssertNotEqual(result2.value(), fakePeripheral.identifier.hash); + + std::optional result3 = + _medium->RetrieveBlePeripheralIdFromNativeId("invalid-uuid-string"); + XCTAssertFalse(result3.has_value()); +} + +#pragma mark - HandleAdvertisementFound Tests + +- (void)testHandleAdvertisementFound_NewPeripheral { + GNCFakePeripheral *fakePeripheral = [[GNCFakePeripheral alloc] init]; + NSDictionary *serviceData = + @{[CBUUID UUIDWithString:kTestServiceUUIDString] : [NSData dataWithBytes:"test" length:4]}; + + XCTestExpectation *expectation = + [self expectationWithDescription:@"Advertisement found callback"]; + + nearby::api::ble::BleMedium::ScanCallback callback = { + .advertisement_found_cb = std::function( + ^(nearby::api::ble::BlePeripheral::UniqueId peripheral_id, + const nearby::api::ble::BleAdvertisementData &advertisement) { + XCTAssertEqual(peripheral_id, fakePeripheral.identifier.hash); + XCTAssertEqual(advertisement.service_data.size(), 1); + CBUUID *serviceUUID = + nearby::apple::CBUUID128FromCPP(advertisement.service_data.begin()->first); + XCTAssertEqualObjects(serviceUUID.UUIDString, kTestServiceUUIDString); + [expectation fulfill]; + })}; + + _medium->StartScanning(nearby::Uuid(0, 0), nearby::api::ble::TxPowerLevel::kUltraLow, + std::move(callback)); + + if (_fakeGNCBLEMedium.advertisementFoundHandler) { + _fakeGNCBLEMedium.advertisementFoundHandler(fakePeripheral, serviceData); + } + + [self waitForExpectations:@[ expectation ] timeout:1.0]; +} + +- (void)testHandleAdvertisementFound_KnownPeripheral_NewData { + GNCFakePeripheral *fakePeripheral = [[GNCFakePeripheral alloc] init]; + NSDictionary *serviceData1 = + @{[CBUUID UUIDWithString:kTestServiceUUIDString] : [NSData dataWithBytes:"test1" length:5]}; + NSDictionary *serviceData2 = + @{[CBUUID UUIDWithString:kTestServiceUUIDString] : [NSData dataWithBytes:"test2" length:5]}; + + XCTestExpectation *expectation1 = [self expectationWithDescription:@"Callback 1"]; + nearby::api::ble::BleMedium::ScanCallback callback1 = { + .advertisement_found_cb = std::function( + ^(nearby::api::ble::BlePeripheral::UniqueId peripheral_id, + const nearby::api::ble::BleAdvertisementData &advertisement) { + [expectation1 fulfill]; + })}; + _medium->StartScanning(nearby::Uuid(0, 0), nearby::api::ble::TxPowerLevel::kUltraLow, + std::move(callback1)); + if (_fakeGNCBLEMedium.advertisementFoundHandler) { + _fakeGNCBLEMedium.advertisementFoundHandler(fakePeripheral, serviceData1); + } + [self waitForExpectations:@[ expectation1 ] timeout:1.0]; + + XCTestExpectation *expectation2 = [self expectationWithDescription:@"Callback 2"]; + nearby::api::ble::BleMedium::ScanCallback callback2 = { + .advertisement_found_cb = std::function( + ^(nearby::api::ble::BlePeripheral::UniqueId peripheral_id, + const nearby::api::ble::BleAdvertisementData &advertisement) { + XCTAssertEqual(peripheral_id, fakePeripheral.identifier.hash); + XCTAssertEqual(advertisement.service_data.size(), 1); + CBUUID *serviceUUID = + nearby::apple::CBUUID128FromCPP(advertisement.service_data.begin()->first); + XCTAssertEqualObjects(serviceUUID.UUIDString, kTestServiceUUIDString); + NSData *data = [NSData dataWithBytes:advertisement.service_data.begin()->second.data() + length:advertisement.service_data.begin()->second.size()]; + XCTAssertEqualObjects(data, + serviceData2[[CBUUID UUIDWithString:kTestServiceUUIDString]]); + [expectation2 fulfill]; + })}; + _medium->StartScanning(nearby::Uuid(0, 0), nearby::api::ble::TxPowerLevel::kUltraLow, + std::move(callback2)); + + if (_fakeGNCBLEMedium.advertisementFoundHandler) { + _fakeGNCBLEMedium.advertisementFoundHandler(fakePeripheral, serviceData2); + } + + [self waitForExpectations:@[ expectation2 ] timeout:1.0]; +} + +- (void)testHandleAdvertisementFound_KnownPeripheral_SameData_WithinThreshold { + GNCFakePeripheral *fakePeripheral = [[GNCFakePeripheral alloc] init]; + NSDictionary *serviceData = + @{[CBUUID UUIDWithString:kTestServiceUUIDString] : [NSData dataWithBytes:"test" length:4]}; + + __block XCTestExpectation *expectation1 = [self expectationWithDescription:@"Callback 1"]; + XCTestExpectation *expectation2 = [self expectationWithDescription:@"Callback 2"]; + expectation2.inverted = YES; // Should NOT be called. + + nearby::api::ble::BleMedium::ScanCallback callback = { + .advertisement_found_cb = std::function( + ^(nearby::api::ble::BlePeripheral::UniqueId peripheral_id, + const nearby::api::ble::BleAdvertisementData &advertisement) { + if ([expectation1.description isEqualToString:@"Callback 1"]) { + [expectation1 fulfill]; + expectation1 = nil; // Prevent double fulfillment + } else { + [expectation2 fulfill]; + } + })}; + _medium->StartScanning(nearby::Uuid(0, 0), nearby::api::ble::TxPowerLevel::kUltraLow, + std::move(callback)); + if (_fakeGNCBLEMedium.advertisementFoundHandler) { + _fakeGNCBLEMedium.advertisementFoundHandler(fakePeripheral, serviceData); + } + [self waitForExpectations:@[ expectation1 ] timeout:1.0]; + + if (_fakeGNCBLEMedium.advertisementFoundHandler) { + _fakeGNCBLEMedium.advertisementFoundHandler(fakePeripheral, serviceData); + } + + [self waitForExpectations:@[ expectation2 ] timeout:1.0]; +} + +- (void)testHandleAdvertisementFound_KnownPeripheral_SameData_OutsideThreshold { + GNCFakePeripheral *fakePeripheral = [[GNCFakePeripheral alloc] init]; + NSDictionary *serviceData = + @{[CBUUID UUIDWithString:kTestServiceUUIDString] : [NSData dataWithBytes:"test" length:4]}; + + XCTestExpectation *expectation1 = [self expectationWithDescription:@"Callback 1"]; + nearby::api::ble::BleMedium::ScanCallback callback1 = { + .advertisement_found_cb = std::function( + ^(nearby::api::ble::BlePeripheral::UniqueId peripheral_id, + const nearby::api::ble::BleAdvertisementData &advertisement) { + [expectation1 fulfill]; + })}; + _medium->StartScanning(nearby::Uuid(0, 0), nearby::api::ble::TxPowerLevel::kUltraLow, + std::move(callback1)); + if (_fakeGNCBLEMedium.advertisementFoundHandler) { + _fakeGNCBLEMedium.advertisementFoundHandler(fakePeripheral, serviceData); + } + [self waitForExpectations:@[ expectation1 ] timeout:1.0]; + + [NSThread sleepForTimeInterval:2.1]; // Wait for longer than the threshold + + XCTestExpectation *expectation2 = [self expectationWithDescription:@"Callback 2"]; + nearby::api::ble::BleMedium::ScanCallback callback2 = { + .advertisement_found_cb = std::function( + ^(nearby::api::ble::BlePeripheral::UniqueId peripheral_id, + const nearby::api::ble::BleAdvertisementData &advertisement) { + [expectation2 fulfill]; + })}; + _medium->StartScanning(nearby::Uuid(0, 0), nearby::api::ble::TxPowerLevel::kUltraLow, + std::move(callback2)); + + if (_fakeGNCBLEMedium.advertisementFoundHandler) { + _fakeGNCBLEMedium.advertisementFoundHandler(fakePeripheral, serviceData); + } + + [self waitForExpectations:@[ expectation2 ] timeout:1.0]; +} + +@end diff --git a/internal/platform/implementation/apple/ble_medium.h b/internal/platform/implementation/apple/ble_medium.h index a42fca59..5efac96d 100644 --- a/internal/platform/implementation/apple/ble_medium.h +++ b/internal/platform/implementation/apple/ble_medium.h @@ -47,6 +47,8 @@ namespace apple { class BleMedium : public api::ble::BleMedium { public: BleMedium(); + // For testing only. + explicit BleMedium(GNCBLEMedium *medium); ~BleMedium() override; // Async interface for StartAdvertising. diff --git a/internal/platform/implementation/apple/ble_medium.mm b/internal/platform/implementation/apple/ble_medium.mm index 49f7700d..604844df 100644 --- a/internal/platform/implementation/apple/ble_medium.mm +++ b/internal/platform/implementation/apple/ble_medium.mm @@ -52,6 +52,7 @@ #import "internal/platform/implementation/apple/ble_socket.h" #import "internal/platform/implementation/apple/bluetooth_adapter_v2.h" #import "internal/platform/implementation/apple/utils.h" +#import "internal/platform/implementation/apple/GNCUtils.h" static NSString *const kWeaveServiceUUID = @"FEF3"; static const char *const kConnectionCallbackQueueLabel = @@ -64,27 +65,10 @@ static NSTimeInterval const kAdvertisementPacketsMapExpirationTimeInterval = 600 namespace nearby { namespace apple { -namespace { -NSString *ConvertDataToHexString(NSData *data) { - NSUInteger dataLength = data.length; - if (dataLength == 0) { - return @"0x"; - } +BleMedium::BleMedium() : BleMedium([[GNCBLEMedium alloc] init]) {} - const unsigned char *dataBuffer = (const unsigned char *)data.bytes; - NSMutableString *hexString = [NSMutableString stringWithCapacity:dataLength * 2]; - - for (NSUInteger i = 0; i < dataLength; ++i) { - [hexString appendFormat:@"%02lx", (unsigned long)dataBuffer[i]]; - } - - return [NSString stringWithFormat:@"0x%@", hexString]; -} - -} // namespace - -BleMedium::BleMedium() : medium_([[GNCBLEMedium alloc] init]) { +BleMedium::BleMedium(GNCBLEMedium *medium) : medium_(medium) { connection_callback_queue_ = dispatch_queue_create(kConnectionCallbackQueueLabel, DISPATCH_QUEUE_SERIAL); } @@ -184,7 +168,7 @@ void BleMedium::HandleAdvertisementFound(id peripheral, for (NSData *service_data in serviceData.allValues) { GNCLoggerDebug(@"Reporting the advertisement packet to upper layer for unique_id: %llu, %@, " @"advertisement_data: %@.", - unique_id, peripheral, ConvertDataToHexString(service_data)); + unique_id, peripheral, GNCConvertDataToHexString(service_data)); } #endif @@ -211,6 +195,9 @@ std::unique_ptr BleMedium::StartScanning( socketCentralManager_ = [[GNSCentralManager alloc] initWithSocketServiceUUID:serviceUUID]; [socketCentralManager_ startNoScanModeWithAdvertisedServiceUUIDs:@[ serviceUUID ]]; + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block NSError *blockError = nil; + [medium_ startScanningForService:serviceUUID advertisementFoundHandler:^(id peripheral, NSDictionary *serviceData) { @@ -218,13 +205,30 @@ std::unique_ptr BleMedium::StartScanning( [this, peripheral, serviceData] { HandleAdvertisementFound(peripheral, serviceData); }); } completionHandler:^(NSError *error) { + blockError = error; if (scanning_cb_.start_scanning_result) { scanning_cb_.start_scanning_result( error == nil ? absl::OkStatus() : absl::InternalError(error.localizedDescription.UTF8String)); } + dispatch_semaphore_signal(semaphore); }]; + dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, kApiTimeoutInSeconds * NSEC_PER_SEC); + if (dispatch_semaphore_wait(semaphore, timeout) != 0) { + GNCLoggerError(@"Start scanning operation timed out."); + if (scanning_cb_.start_scanning_result) { + scanning_cb_.start_scanning_result(absl::DeadlineExceededError("Start scanning timed out")); + } + return nullptr; + } + + if (blockError) { + GNCLoggerError(@"Failed to start scanning: %@", blockError); + // The start_scanning_result callback was already called in the completionHandler with the error. + return nullptr; + } + return std::make_unique(ScanningSession{.stop_scanning = [this] { return StopScanning() ? absl::OkStatus() : absl::InternalError("Failed to stop scanning"); }});