diff --git a/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEError.h b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEError.h index eb32fee7..0db6d168 100644 --- a/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEError.h +++ b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEError.h @@ -26,4 +26,5 @@ typedef NS_ERROR_ENUM(GNCBLEErrorDomain, GNCBLEError){ GNCBLEErrorInvalidCharacteristic, GNCBLEErrorAlreadyDiscoveringSpecifiedCharacteristics, GNCBLEErrorAlreadyReadingCharacteristic, + GNCBLEErrorAlreadyScanning, }; diff --git a/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTClient.h b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTClient.h index c55017fc..f41f8f00 100644 --- a/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTClient.h +++ b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTClient.h @@ -65,7 +65,7 @@ typedef void (^GNCReadCharacteristicValueCompletionHandler)(NSData *_Nullable va * * @param peripheral The peripheral instance. */ -- (instancetype)initWithPeripheral:(CBPeripheral *)peripheral; +- (instancetype)initWithPeripheral:(id)peripheral; /** * Discovers the specified characteristics of a service. diff --git a/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTClient.m b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTClient.m index 21871a87..8b91c879 100644 --- a/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTClient.m +++ b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTClient.m @@ -70,7 +70,7 @@ static NSError *AlreadyReadingCharacteristicError() { *_readCharacteristicValueCompletionHandlers; } -- (instancetype)initWithPeripheral:(CBPeripheral *)peripheral { +- (instancetype)initWithPeripheral:(id)peripheral { return [self initWithPeripheral:peripheral queue:dispatch_queue_create(kGNCBLEGATTClientQueueLabel, DISPATCH_QUEUE_SERIAL)]; diff --git a/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTServer.m b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTServer.m index 2cd60e63..1f996d69 100644 --- a/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTServer.m +++ b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTServer.m @@ -203,7 +203,7 @@ static char *const kGNCBLEGATTServerQueueLabel = "com.nearby.GNCBLEGATTServer"; // data is unavailable. CBUUID *serviceUUID = [serviceData.allKeys objectAtIndex:0]; NSData *value = [serviceData objectForKey:serviceUUID]; - NSString *encoded = [value webSafebase64EncodedString]; + NSString *encoded = [value webSafeBase64EncodedString]; // Base64 encoding increases the size of the data so we must truncate it to 22 bytes to ensure // it fits in the advertisement alongside an assumed 16-bit serviceUUID. diff --git a/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEMedium.h b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEMedium.h new file mode 100644 index 00000000..d81b4975 --- /dev/null +++ b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEMedium.h @@ -0,0 +1,139 @@ +// 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. + +#import +#import + +@class GNCBLEGATTServer; +@class GNCBLEGATTClient; +@class GNCBLEGATTCharacteristic; + +@protocol GNCPeripheral; + +NS_ASSUME_NONNULL_BEGIN + +/** + * A block to be invoked when a call to @c startAdvertisingData:completionHandler: has completed. + * + * @param error The cause of the failure, or @c nil if no error occurred. + */ +typedef void (^GNCStartAdvertisingCompletionHandler)(NSError *_Nullable error); + +/** + * A block to be invoked when a peripheral’s advertisement has been discovered. + * + * @note This block can be called numerous times. + * + * @param peripheral The discovered peripheral. + * @param serviceData A dictionary that contains service-specific advertisement data. The keys + * represent services and the values represent the service-specific data. + */ +typedef void (^GNCAdvertisementFoundHandler)(id peripheral, + NSDictionary *serviceData); + +/** + * A block to be invoked when a call to + * @c startScanningForService:advertisementFoundHandler:completionHandler: has completed. + * + * @param error The cause of the failure, or @c nil if no error occurred. + */ +typedef void (^GNCStartScanningCompletionHandler)(NSError *_Nullable error); + +/** + * A block to be invoked when a call to @c startGATTServerWithCompletionHandler: has completed. + * + * @param server The successfully started GATT server, or @c nil if an error occurred. + * @param error The cause of the failure, or @c nil if no error occurred. + */ +typedef void (^GNCGATTServerCompletionHandler)(GNCBLEGATTServer *_Nullable server, + NSError *_Nullable error); + +/** A block to be invoked when a peripheral has disconnected. */ +typedef void (^GNCGATTDisconnectionHandler)(); + +/** + * A block to be invoked when a call to + * @c connectToGATTServerForPeripheral:disconnectionHandler:completionHandler: has completed. + * + * @param client The interface to the remote peripheral’s GATT server, or @c nil if an error + * occurred. + * @param error The cause of the failure, or @c nil if no error occurred. + */ +typedef void (^GNCGATTConnectionCompletionHandler)(GNCBLEGATTClient *_Nullable client, + NSError *_Nullable error); + +/** + * The main BLE medium used inside of Nearby. This serves as the entry point for all BLE and GATT + * related operations. + * + * @note The public APIs of this class are thread safe. + */ +@interface GNCBLEMedium : NSObject + +/** The hardware supports BOTH advertising extensions and extended scans. */ +@property(nonatomic, readonly) BOOL supportsExtendedAdvertisements; + +/** + * Starts advertising service data in a way that is supported by CoreBluetooth. + * + * Since CoreBluetooth doesn't support setting the @c CBAdvertisementDataServiceDataKey key, the + * service list is advertised using @c CBAdvertisementDataServiceUUIDsKey and the associated data is + * advertised using @c CBAdvertisementDataLocalNameKey. Since @c CBAdvertisementDataLocalNameKey + * does not support binary data, the value is base64 encoded and truncated if the resulting value is + * longer than 22 bytes. This also means we can only support advertising a single service. + * + * @param serviceData A dictionary that contains service-specific advertisement data. + * @param completionHandler Called on a private queue with @c nil if successfully started + * advertising or an error if one has occured. + */ +- (void)startAdvertisingData:(NSDictionary *)serviceData + completionHandler:(nullable GNCStartAdvertisingCompletionHandler)completionHandler; + +/** + * Scans for peripherals that are advertising the specified service. + * + * @param serviceUUID The service UUID to scan for. + * @param advertisementFoundHandler Called on a private queue when a peripheral has been discovered. + * @param completionHandler Called on a private queue with @c nil if successfully started scanning + * or an error if one has occured. + */ +- (void)startScanningForService:(CBUUID *)serviceUUID + advertisementFoundHandler:(GNCAdvertisementFoundHandler)advertisementFoundHandler + completionHandler:(nullable GNCStartScanningCompletionHandler)completionHandler; + +/** + * Starts a GATT server. + * + * @param completionHandler Called on a private queue with the GATT server if successfully started + * or an error if one has occured. + */ +- (void)startGATTServerWithCompletionHandler: + (nullable GNCGATTServerCompletionHandler)completionHandler; + +/** + * Connects to a peripheral’s GATT server. + * + * @param remotePeripheral The peripheral to which the central is attempting to connect. + * @param disconnectionHandler Called on a private queue when the peripheral has been disconnected. + * @param completionHandler Called on a private queue with a GATT client if successfully connected + * or an error if one has occured. + */ +- (void)connectToGATTServerForPeripheral:(id)remotePeripheral + disconnectionHandler:(nullable GNCGATTDisconnectionHandler)disconnectionHandler + completionHandler: + (nullable GNCGATTConnectionCompletionHandler)completionHandler; + +@end + +NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEMedium.m b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEMedium.m new file mode 100644 index 00000000..7f1c0735 --- /dev/null +++ b/internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEMedium.m @@ -0,0 +1,294 @@ +// 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. + +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEMedium.h" + +#import +#import + +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEError.h" +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTClient.h" +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTServer.h" +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCCentralManager.h" +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCPeripheral.h" +#import "internal/platform/implementation/apple/Mediums/BLEv2/NSData+GNCWebSafeBase64.h" + +NS_ASSUME_NONNULL_BEGIN + +static char *const kBLEMediumQueueLabel = "com.nearby.GNCBLEMedium"; + +static NSError *AlreadyScanningError() { + return [NSError errorWithDomain:GNCBLEErrorDomain code:GNCBLEErrorAlreadyScanning userInfo:nil]; +} + +@interface GNCBLEMedium () +@end + +@implementation GNCBLEMedium { + dispatch_queue_t _queue; + id _centralManager; + + // The active GATT server, or @nil if one hasn't been started yet. + GNCBLEGATTServer *_server; + + // The service that is being actively scanned for, or @c nil if not currently scanning. + CBUUID *_serviceUUID; + + // The handler called when an advertisement for the service represented by @c _serviceUUID has + // been discovered. This will be called continuously, until the peripheral disappears. + GNCAdvertisementFoundHandler _advertisementFoundHandler; + + // A peripheral to connection completion handler map. Used to track connection attempts. When a + // connection attempt has succeeded or failed, the completion handler is called and removed from + // the map. + NSMutableDictionary *_connectionCompletionHandlers; + + // A peripheral to disconnection handler map. Used to track when a peripheral becomes + // disconnected. Once disconnected, the completion handler is called and removed from the map. + NSMutableDictionary *_disconnectionHandlers; +} + +- (instancetype)init { + CBCentralManager *centralManager = + [[CBCentralManager alloc] initWithDelegate:self + queue:_queue + options:@{CBCentralManagerOptionShowPowerAlertKey : @NO}]; + dispatch_queue_t queue = dispatch_queue_create(kBLEMediumQueueLabel, DISPATCH_QUEUE_SERIAL); + return [self initWithCentralManager:centralManager queue:queue]; +} + +// This is private and should only be used for tests. The provided central manager must call +// delegate methods on the main queue. +- (instancetype)initWithCentralManager:(id)centralManager + queue:(nullable dispatch_queue_t)queue { + self = [super init]; + if (self) { + _queue = queue ?: dispatch_get_main_queue(); + _centralManager = centralManager; + _centralManager.centralDelegate = self; + _connectionCompletionHandlers = [NSMutableDictionary dictionary]; + _disconnectionHandlers = [NSMutableDictionary dictionary]; + } + return self; +} + +- (BOOL)supportsExtendedAdvertisements { + // TODO(b/294736083): CoreBluetooth doesn't support actually advertising any extensions, however + // some devices can scan for them if the feature is available. If we return @c YES from this + // method, we would be enabling advertising extensions (which won't work), so we must return @c NO + // until we add support for a new method to check only if extended scans are supported. + return NO; +} + +- (void)startAdvertisingData:(NSDictionary *)serviceData + completionHandler:(nullable GNCStartAdvertisingCompletionHandler)completionHandler { + dispatch_async(_queue, ^{ + if (!_server) { + _server = [[GNCBLEGATTServer alloc] init]; + } + [_server startAdvertisingData:serviceData completionHandler:completionHandler]; + }); +} + +- (void)startScanningForService:(CBUUID *)serviceUUID + advertisementFoundHandler:(GNCAdvertisementFoundHandler)advertisementFoundHandler + completionHandler:(nullable GNCStartScanningCompletionHandler)completionHandler { + dispatch_async(_queue, ^{ + if (_serviceUUID) { + if (completionHandler) { + completionHandler(AlreadyScanningError()); + } + return; + } + + _serviceUUID = serviceUUID; + _advertisementFoundHandler = advertisementFoundHandler; + + [self internalStartScanningIfPoweredOn]; + if (completionHandler) { + completionHandler(nil); + } + }); +} + +- (void)startGATTServerWithCompletionHandler: + (nullable GNCGATTServerCompletionHandler)completionHandler { + dispatch_async(_queue, ^{ + if (!_server) { + _server = [[GNCBLEGATTServer alloc] init]; + } + if (completionHandler) { + completionHandler(_server, nil); + } + }); +} + +- (void)connectToGATTServerForPeripheral:(id)remotePeripheral + disconnectionHandler:(nullable GNCGATTDisconnectionHandler)disconnectionHandler + completionHandler: + (nullable GNCGATTConnectionCompletionHandler)completionHandler { + dispatch_async(_queue, ^{ + _disconnectionHandlers[remotePeripheral.identifier] = disconnectionHandler; + _connectionCompletionHandlers[remotePeripheral.identifier] = completionHandler; + [_centralManager connectPeripheral:remotePeripheral options:@{}]; + }); +} + +#pragma mark - Internal + +- (void)internalStartScanningIfPoweredOn { + dispatch_assert_queue(_queue); + // Scanning can only be done when powered on and must be restarted if bluetooth is turned off + // then back on. This will be called anytime the central manager's state changes, so + // @c scanForPeripheralsWithServices:options: will be called anytime state transitions back to + // powered on. + if (_centralManager.state == CBManagerStatePoweredOn && _serviceUUID != nil) { + // Stop scanning just in case something outside of this class is already scanning. + [_centralManager stopScan]; + [_centralManager + scanForPeripheralsWithServices:@[ _serviceUUID ] + // Nearby relies on the existence of an advertisement for endpoint + // discovery/lost events, so we must set this key to keep the stream + // of duplicate delegate events flowing. This has adverse effect on + // battery life, but currently necessary. + options:@{CBCentralManagerScanOptionAllowDuplicatesKey : @YES}]; + } +} + +- (NSDictionary *)decodeAdvertisementData: + (NSDictionary *)advertisementData { + dispatch_assert_queue(_queue); + // If service data is available, return it directly. + NSDictionary *serviceData = + advertisementData[CBAdvertisementDataServiceDataKey]; + if (serviceData) { + return serviceData; + } + + // Apple devices don't support advertising service data, so Apple devices advertise a base64 + // encoded local name, while other devices advertise service data. Here we attempt to reconstruct + // service data by decoding the local name. If successful, this is possibly a Nearby advertisement + // on an Apple device. + NSString *localName = advertisementData[CBAdvertisementDataLocalNameKey]; + if (!localName) { + return @{}; + } + NSData *data = [[NSData alloc] initWithWebSafeBase64EncodedString:localName]; + + // A Nearby Apple advertisement should only have a single service, so simply grab the first one if + // it exists. + NSArray *serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey]; + CBUUID *serviceUUID = serviceUUIDs.firstObject; + if (data && serviceUUID) { + return @{serviceUUID : data}; + } + + return @{}; +} + +#pragma mark - GNCCentralManagerDelegate + +- (void)gnc_centralManagerDidUpdateState:(id)central { + dispatch_assert_queue(_queue); + [self internalStartScanningIfPoweredOn]; +} + +- (void)gnc_centralManager:(id)central + didDiscoverPeripheral:(id)peripheral + advertisementData:(NSDictionary *)advertisementData + RSSI:(NSNumber *)RSSI { + dispatch_assert_queue(_queue); + if (_advertisementFoundHandler) { + _advertisementFoundHandler(peripheral, [self decodeAdvertisementData:advertisementData]); + } +} + +- (void)gnc_centralManager:(id)central + didConnectPeripheral:(id)peripheral { + dispatch_assert_queue(_queue); + GNCGATTConnectionCompletionHandler handler = _connectionCompletionHandlers[peripheral.identifier]; + _connectionCompletionHandlers[peripheral.identifier] = nil; + if (handler) { + GNCBLEGATTClient *client = [[GNCBLEGATTClient alloc] initWithPeripheral:peripheral]; + handler(client, nil); + } +} + +- (void)gnc_centralManager:(id)central + didFailToConnectPeripheral:(id)peripheral + error:(nullable NSError *)error { + dispatch_assert_queue(_queue); + GNCGATTConnectionCompletionHandler handler = _connectionCompletionHandlers[peripheral.identifier]; + _connectionCompletionHandlers[peripheral.identifier] = nil; + if (handler) { + handler(nil, error); + } +} + +- (void)gnc_centralManager:(id)central + didDisconnectPeripheral:(id)peripheral + error:(nullable NSError *)error { + dispatch_assert_queue(_queue); + GNCGATTDisconnectionHandler handler = _disconnectionHandlers[peripheral.identifier]; + _disconnectionHandlers[peripheral.identifier] = nil; + if (handler) { + handler(); + } +} + +#pragma mark - CBCentralManagerDelegate + +- (void)centralManagerDidUpdateState:(CBCentralManager *)central { + dispatch_async(_queue, ^{ + [self gnc_centralManagerDidUpdateState:central]; + }); +} + +- (void)centralManager:(CBCentralManager *)central + didDiscoverPeripheral:(CBPeripheral *)peripheral + advertisementData:(NSDictionary *)advertisementData + RSSI:(NSNumber *)RSSI { + dispatch_async(_queue, ^{ + [self gnc_centralManager:central + didDiscoverPeripheral:peripheral + advertisementData:advertisementData + RSSI:RSSI]; + }); +} + +- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral { + dispatch_async(_queue, ^{ + [self gnc_centralManager:central didConnectPeripheral:peripheral]; + }); +} + +- (void)centralManager:(CBCentralManager *)central + didFailToConnectPeripheral:(CBPeripheral *)peripheral + error:(nullable NSError *)error { + dispatch_async(_queue, ^{ + [self gnc_centralManager:central didFailToConnectPeripheral:peripheral error:error]; + }); +} + +- (void)centralManager:(CBCentralManager *)central + didDisconnectPeripheral:(CBPeripheral *)peripheral + error:(nullable NSError *)error { + dispatch_async(_queue, ^{ + [self gnc_centralManager:central didDisconnectPeripheral:peripheral error:error]; + }); +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Mediums/BLEv2/GNCCentralManager.h b/internal/platform/implementation/apple/Mediums/BLEv2/GNCCentralManager.h new file mode 100644 index 00000000..770377ea --- /dev/null +++ b/internal/platform/implementation/apple/Mediums/BLEv2/GNCCentralManager.h @@ -0,0 +1,181 @@ +// 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. + +#import +#import + +@protocol GNCCentralManagerDelegate; +@protocol GNCPeripheral; + +NS_ASSUME_NONNULL_BEGIN + +/** Protocol which helps create a fake of a @c CBCentralManager to inject for testing. */ +@protocol GNCCentralManager + +/** Shadow property of a @c CBCentralManagerDelegate. */ +@property(weak, nonatomic, nullable) id centralDelegate; + +/** + * The current state of the manager. + * + * This state is initially set to @c CBManagerStateUnknown. When the state updates, the manager + * calls its delegate’s @c gnc_centralManagerDidUpdateState: method. + */ +@property(nonatomic, assign, readonly) CBManagerState state; + +/** + * Scans for peripherals that are advertising services. + * + * You can provide an array of @c CBUUID objects, representing service UUIDs, in the @c serviceUUIDs + * parameter. When you do, the central manager returns only peripherals that advertise the services + * you specify. If the @c serviceUUIDs parameter is @c nil, this method returns all discovered + * peripherals, regardless of their supported services. + * + * @note The recommended practice is to populate the @c serviceUUIDs parameter rather than leaving + * it @c nil. + * + * If the central manager is actively scanning with one set of parameters and it receives another + * set to scan, the new parameters override the previous set. When the central manager discovers a + * peripheral, it calls the @c gnc_centralManager:didDiscoverPeripheral:advertisementData:RSSI: + * method of its delegate object. + * + * Your app can scan for Bluetooth devices in the background by specifying the @c bluetooth-central + * background mode. To do this, your app must explicitly scan for one or more services by specifying + * them in the @c serviceUUIDs parameter. The CBCentralManager scan option has no effect while + * scanning in the background. + * + * @param serviceUUIDs An array of @c CBUUID objects that the app is interested in. Each @c CBUUID + * object represents the UUID of a service that a peripheral advertises. + * @param options A dictionary of options for customizing the scan. + */ +- (void)scanForPeripheralsWithServices:(nullable NSArray *)serviceUUIDs + options:(nullable NSDictionary *)options; + +/** + * Establishes a local connection to a peripheral. + * + * After successfully establishing a local connection to a peripheral, the central manager object + * calls the @c gnc_centralManager:didConnectPeripheral: method of its delegate object. If the + * connection attempt fails, the central manager object calls the + * @c gnc_centralManager:didFailToConnectPeripheral:error: method of its delegate object instead. + * Attempts to connect to a peripheral don’t time out. To explicitly cancel a pending connection to + * a peripheral, call the @c cancelPeripheralConnection: method. Deallocating @c peripheral also + * implicitly calls @c cancelPeripheralConnection:. + * + * @param peripheral The peripheral to which the central is attempting to connect. + * @param options A dictionary to customize the behavior of the connection. + */ +- (void)connectPeripheral:(id)peripheral + options:(nullable NSDictionary *)options; + +/** Asks the central manager to stop scanning for peripherals. */ +- (void)stopScan; + +@end + +/** + * Protocol which helps the @c GNCCentralManager wrap a @c CBCentralManagerDelegate for + * testing. + */ +@protocol GNCCentralManagerDelegate + +/** + * Tells the delegate the central manager’s state updated. + * + * You implement this required method to ensure that the central device supports Bluetooth low + * energy and that it’s available to use. You should issue commands to the central manager only when + * the central manager’s @c state indicates it’s powered on. A state with a value lower than + * @c CBManagerStatePoweredOn implies that scanning has stopped, which in turn disconnects any + * previously-connected peripherals. If the state moves below @c CBManagerStatePoweredOff, all + * @c CBPeripheral objects obtained from this central manager become invalid; you must retrieve or + * discover these peripherals again. + * + * @param central The central manager whose state has changed. + */ +- (void)gnc_centralManagerDidUpdateState:(id)central; + +/** + * Tells the delegate the central manager discovered a peripheral while scanning for devices. + * + * You must retain a local copy of the peripheral if you want to perform commands on it. Use the + * RSSI data to determine the proximity of a discoverable peripheral device, and whether you want to + * connect to it automatically. + * + * @param central The central manager that provides the update. + * @param peripheral The discovered peripheral. + * @param advertisementData A dictionary containing any advertisement data. + * @param RSSI The current received signal strength indicator (RSSI) of the peripheral, in decibels. + */ +- (void)gnc_centralManager:(id)central + didDiscoverPeripheral:(id)peripheral + advertisementData:(NSDictionary *)advertisementData + RSSI:(NSNumber *)RSSI; + +/** + * Tells the delegate that the central manager connected to a peripheral. + * + * The manager invokes this method when a call to @c connectPeripheral:options: succeeds. You + * typically implement this method to set the peripheral’s delegate and discover its services. + * + * @param central The central manager that provides this information. + * @param peripheral The now-connected peripheral. + */ +- (void)gnc_centralManager:(id)central + didConnectPeripheral:(id)peripheral; + +/** + * Tells the delegate the central manager failed to create a connection with a peripheral. + * + * The manager invokes this method when a connection initiated with the + * @c connectPeripheral:options: method fails to complete. Because connection attempts don’t time + * out, a failed connection usually indicates a transient issue, in which case you may attempt + * connecting to the peripheral again. + * + * @param central The central manager that provides this information. + * @param peripheral The peripheral that failed to connect. + * @param error The cause of the failure, or @c nil if no error occurred. + */ +- (void)gnc_centralManager:(id)central + didFailToConnectPeripheral:(id)peripheral + error:(nullable NSError *)error; + +/** + * Tells the delegate that the central manager disconnected from a peripheral. + * + * The manager invokes this method when disconnecting a peripheral previously connected with the + * @c connectPeripheral:options: method. The error parameter contains the reason for the + * disconnection, unless the disconnect resulted from a call to @c cancelPeripheralConnection:. + * + * All services, characteristics, and characteristic descriptors of a peripheral become invalidated + * after it disconnects. + * + * @param central The central manager that provides this information. + * @param peripheral The now-disconnected peripheral. + * @param error The cause of the failure, or @c nil if no error occurred. + */ +- (void)gnc_centralManager:(id)central + didDisconnectPeripheral:(id)peripheral + error:(nullable NSError *)error; + +@end + +/** + * Declares that @c CBCentralManager implements the @c GNCCentralManager protocol. + * + * This allows us to directly use a @c CBCentralManager as a @c GNCCentralManager. + */ +@interface CBCentralManager () +@end + +NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Mediums/BLEv2/GNCCentralManager.m b/internal/platform/implementation/apple/Mediums/BLEv2/GNCCentralManager.m new file mode 100644 index 00000000..921e8d92 --- /dev/null +++ b/internal/platform/implementation/apple/Mediums/BLEv2/GNCCentralManager.m @@ -0,0 +1,36 @@ +// 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. + +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCCentralManager.h" + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@implementation CBCentralManager (GNCCentralManagerAdditions) + +- (void)setCentralDelegate:(nullable id)centralDelegate { + NSAssert([centralDelegate conformsToProtocol:@protocol(CBCentralManagerDelegate)], + @"centralDelegate must conform to protocol CBCentralManagerDelegate"); + self.delegate = (id)centralDelegate; +} + +- (nullable id)centralDelegate { + return (id)self.delegate; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Mediums/BLEv2/GNCPeripheral.h b/internal/platform/implementation/apple/Mediums/BLEv2/GNCPeripheral.h index d3a7a1f8..c43b7b6b 100644 --- a/internal/platform/implementation/apple/Mediums/BLEv2/GNCPeripheral.h +++ b/internal/platform/implementation/apple/Mediums/BLEv2/GNCPeripheral.h @@ -43,6 +43,16 @@ NS_ASSUME_NONNULL_BEGIN */ @property(retain, readonly, nullable) NSArray *services; +/** + * The UUID associated with the peer. + * + * The value of this property represents the unique identifier of the peer. The first time a local + * manager encounters a peer, the system assigns the peer a UUID, represented by a new @c NSUUID + * object. Peers use @c NSUUID instances to identify themselves, instead of by the @c CBUUID objects + * that identify a peripheral’s services, characteristics, and descriptors. + */ +@property(readonly, nonatomic) NSUUID *identifier; + /** * Discovers the specified services of the peripheral. * diff --git a/internal/platform/implementation/apple/Mediums/BLEv2/NSData+GNCWebSafeBase64.h b/internal/platform/implementation/apple/Mediums/BLEv2/NSData+GNCWebSafeBase64.h index c4d03b74..1143cef2 100644 --- a/internal/platform/implementation/apple/Mediums/BLEv2/NSData+GNCWebSafeBase64.h +++ b/internal/platform/implementation/apple/Mediums/BLEv2/NSData+GNCWebSafeBase64.h @@ -19,7 +19,16 @@ NS_ASSUME_NONNULL_BEGIN @interface NSData (GNCWebSafeBase64) /** Creates a Base64 encoded string from the data using websafe characters and no padding. */ -- (NSString *)webSafebase64EncodedString; +- (NSString *)webSafeBase64EncodedString; + +/** + * Initializes a data object with the given Base64 encoded string. + * + * @param base64String A Base64 encoded string. + * @return A data object built by Base64 decoding the provided string. Returns @c nil if the data + * object could not be decoded. + */ +- (nullable instancetype)initWithWebSafeBase64EncodedString:(NSString *)base64String; @end diff --git a/internal/platform/implementation/apple/Mediums/BLEv2/NSData+GNCWebSafeBase64.m b/internal/platform/implementation/apple/Mediums/BLEv2/NSData+GNCWebSafeBase64.m index 1f0b3014..e2dd69cb 100644 --- a/internal/platform/implementation/apple/Mediums/BLEv2/NSData+GNCWebSafeBase64.m +++ b/internal/platform/implementation/apple/Mediums/BLEv2/NSData+GNCWebSafeBase64.m @@ -14,11 +14,13 @@ #import "internal/platform/implementation/apple/Mediums/BLEv2/NSData+GNCWebSafeBase64.h" +#import + NS_ASSUME_NONNULL_BEGIN @implementation NSData (GNCWebSafeBase64) -- (NSString *)webSafebase64EncodedString { +- (NSString *)webSafeBase64EncodedString { NSString *encoded = [self base64EncodedStringWithOptions:0]; // Convert the standard base64 characters to URL safe variants. @@ -29,6 +31,20 @@ NS_ASSUME_NONNULL_BEGIN return encoded; } +- (nullable instancetype)initWithWebSafeBase64EncodedString:(NSString *)base64String { + // Convert the URL safe base64 characters to the standard variants. + base64String = [base64String stringByReplacingOccurrencesOfString:@"-" withString:@"+"]; + base64String = [base64String stringByReplacingOccurrencesOfString:@"_" withString:@"/"]; + + // @c initWithBase64EncodedString:options: requires a padded base64 string. Append enough "=" + // characters to make the string a multiple of 4. + NSUInteger paddedLength = base64String.length + ((4 - (base64String.length % 4)) % 4); + base64String = [base64String stringByPaddingToLength:paddedLength + withString:@"=" + startingAtIndex:0]; + return [self initWithBase64EncodedString:base64String options:0]; +} + @end NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Mediums/BUILD b/internal/platform/implementation/apple/Mediums/BUILD index aef72927..d9f5917d 100644 --- a/internal/platform/implementation/apple/Mediums/BUILD +++ b/internal/platform/implementation/apple/Mediums/BUILD @@ -22,6 +22,8 @@ objc_library( "BLEv2/GNCBLEGATTCharacteristic.m", "BLEv2/GNCBLEGATTClient.m", "BLEv2/GNCBLEGATTServer.m", + "BLEv2/GNCBLEMedium.m", + "BLEv2/GNCCentralManager.m", "BLEv2/GNCPeripheral.m", "BLEv2/GNCPeripheralManager.m", "BLEv2/NSData+GNCWebSafeBase64.m", @@ -44,6 +46,8 @@ objc_library( "BLEv2/GNCBLEGATTCharacteristic.h", "BLEv2/GNCBLEGATTClient.h", "BLEv2/GNCBLEGATTServer.h", + "BLEv2/GNCBLEMedium.h", + "BLEv2/GNCCentralManager.h", "BLEv2/GNCPeripheral.h", "BLEv2/GNCPeripheralManager.h", "BLEv2/NSData+GNCWebSafeBase64.h", diff --git a/internal/platform/implementation/apple/Tests/BUILD b/internal/platform/implementation/apple/Tests/BUILD index 864188a5..2ca87d00 100644 --- a/internal/platform/implementation/apple/Tests/BUILD +++ b/internal/platform/implementation/apple/Tests/BUILD @@ -24,13 +24,18 @@ objc_library( testonly = True, srcs = [ "GNCBLEGATTCharacteristicTest.mm", + "GNCBLEGATTClient+Testing.h", "GNCBLEGATTClientTest.m", "GNCBLEGATTServer+Testing.h", "GNCBLEGATTServerTest.m", + "GNCBLEMedium+Testing.h", + "GNCBLEMediumTest.m", "GNCBLEUtilsTest.mm", "GNCBleTest.mm", "GNCBluetoothAdapterTest.mm", "GNCCryptoTest.mm", + "GNCFakeCentralManager.h", + "GNCFakeCentralManager.m", "GNCFakePeripheral.h", "GNCFakePeripheral.m", "GNCFakePeripheralManager.h", @@ -43,7 +48,6 @@ objc_library( "NSData+GNCWebSafeBase64Test.m", ], deps = [ - ":GNCBLEGATTClient_Testing", "//internal/platform:base", "//internal/platform/implementation:comm", "//internal/platform/implementation:platform", @@ -58,15 +62,6 @@ objc_library( ], ) -objc_library( - name = "GNCBLEGATTClient_Testing", - hdrs = ["GNCBLEGATTClient+Testing.h"], - deps = [ - "//internal/platform/implementation/apple/Mediums", - "//third_party/apple_frameworks:Foundation", - ], -) - ios_unit_test( name = "PlatformTests", minimum_os_version = IOS_MINIMUM_OS, diff --git a/internal/platform/implementation/apple/Tests/GNCBLEMedium+Testing.h b/internal/platform/implementation/apple/Tests/GNCBLEMedium+Testing.h new file mode 100644 index 00000000..3431613a --- /dev/null +++ b/internal/platform/implementation/apple/Tests/GNCBLEMedium+Testing.h @@ -0,0 +1,42 @@ +// 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. + +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEMedium.h" + +#import + +@protocol GNCCentralManager; + +NS_ASSUME_NONNULL_BEGIN + +@interface GNCBLEMedium (Testing) + +/** + * Creates a BLE Medium with a provided central manager. + * + * This is only exposed for testing and can be used to inject a fake central manager. + * + * @param centralManager The central manager instance. + * @param queue The queue to run on, this must match the queue that the central manager's delegate + * is running on. Defaults to the main queue when @c nil. + */ +- (instancetype)initWithCentralManager:(id)centralManager + queue:(nullable dispatch_queue_t)queue; + +- (NSDictionary *)decodeAdvertisementData: + (NSDictionary *)advertisementData; + +@end + +NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Tests/GNCBLEMediumTest.m b/internal/platform/implementation/apple/Tests/GNCBLEMediumTest.m new file mode 100644 index 00000000..e71f5fc5 --- /dev/null +++ b/internal/platform/implementation/apple/Tests/GNCBLEMediumTest.m @@ -0,0 +1,294 @@ +// 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. + +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEMedium.h" + +#import +#import +#import + +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCBLEGATTServer.h" +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCPeripheral.h" +#import "internal/platform/implementation/apple/Tests/GNCBLEMedium+Testing.h" +#import "internal/platform/implementation/apple/Tests/GNCFakeCentralManager.h" +#import "internal/platform/implementation/apple/Tests/GNCFakePeripheral.h" + +static NSString *const kServiceUUID = @"0000FEF3-0000-1000-8000-00805F9B34FB"; + +@interface GNCBLEMediumTest : XCTestCase +@end + +@implementation GNCBLEMediumTest + +#pragma mark - Supports Extended Advertisements + +- (void)testSupportsExtendedAdvertisements { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + + XCTAssertFalse([medium supportsExtendedAdvertisements]); +} + +#pragma mark - Start Scanning + +- (void)testStartScanning { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + XCTestExpectation *startScanningExpectation = + [[XCTestExpectation alloc] initWithDescription:@"Start scanning."]; + XCTestExpectation *advertisementFoundExpectation = + [[XCTestExpectation alloc] initWithDescription:@"Advertisement found."]; + CBUUID *serviceUUID = [CBUUID UUIDWithString:kServiceUUID]; + + [fakeCentralManager simulateCentralManagerDidUpdateState:CBManagerStatePoweredOn]; + + [medium startScanningForService:serviceUUID + advertisementFoundHandler:^(id peripheral, + NSDictionary *data) { + NSDictionary *expected = @{ + serviceUUID : [@"test" dataUsingEncoding:NSUTF8StringEncoding], + }; + XCTAssertEqualObjects(expected, data); + [advertisementFoundExpectation fulfill]; + } + completionHandler:^(NSError *error) { + XCTAssertNil(error); + [startScanningExpectation fulfill]; + }]; + + [self waitForExpectations:@[ startScanningExpectation ] timeout:3]; + + XCTAssertEqualObjects(@[ serviceUUID ], fakeCentralManager.serviceUUIDs); + + [fakeCentralManager + simulateCentralManagerDidDiscoverPeripheral:[[GNCFakePeripheral alloc] init] + advertisementData:@{ + CBAdvertisementDataLocalNameKey : @"dGVzdA", + CBAdvertisementDataServiceUUIDsKey : @[ serviceUUID ], + }]; + + [self waitForExpectations:@[ advertisementFoundExpectation ] timeout:3]; +} + +- (void)testAlreadyScanning { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + XCTestExpectation *expectation = + [[XCTestExpectation alloc] initWithDescription:@"Start scanning."]; + + [medium startScanningForService:[CBUUID UUIDWithString:kServiceUUID] + advertisementFoundHandler:^(id peripheral, + NSDictionary *data) { + } + completionHandler:nil]; + + [medium startScanningForService:[CBUUID UUIDWithString:kServiceUUID] + advertisementFoundHandler:^(id peripheral, + NSDictionary *data) { + } + completionHandler:^(NSError *error) { + XCTAssertNotNil(error); + [expectation fulfill]; + }]; + + [self waitForExpectations:@[ expectation ] timeout:3]; +} + +#pragma mark - Decode Advertisement Data + +- (void)testDecodeAndroidStyleAdvertisementData { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + CBUUID *serviceUUID = [CBUUID UUIDWithString:kServiceUUID]; + + NSDictionary *expected = @{ + serviceUUID : [@"test" dataUsingEncoding:NSUTF8StringEncoding], + }; + + NSDictionary *data = @{ + CBAdvertisementDataServiceDataKey : @{ + serviceUUID : [@"test" dataUsingEncoding:NSUTF8StringEncoding], + }, + }; + NSDictionary *actual = [medium decodeAdvertisementData:data]; + + XCTAssertEqualObjects(expected, actual); +} + +- (void)testDecodeAndroidStyleAdvertisementDataWithLocalName { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + CBUUID *serviceUUID = [CBUUID UUIDWithString:kServiceUUID]; + + NSDictionary *expected = @{ + serviceUUID : [@"test" dataUsingEncoding:NSUTF8StringEncoding], + }; + + NSDictionary *data = @{ + CBAdvertisementDataLocalNameKey : @"Nearby", // Just happens to be base64 decodable. + CBAdvertisementDataServiceUUIDsKey : @[ serviceUUID ], + CBAdvertisementDataServiceDataKey : @{ + serviceUUID : [@"test" dataUsingEncoding:NSUTF8StringEncoding], + }, + }; + NSDictionary *actual = [medium decodeAdvertisementData:data]; + + XCTAssertEqualObjects(expected, actual); +} + +- (void)testDecodeAppleStyleAdvertisementData { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + CBUUID *serviceUUID = [CBUUID UUIDWithString:kServiceUUID]; + + NSDictionary *expected = @{ + serviceUUID : [@"test" dataUsingEncoding:NSUTF8StringEncoding], + }; + + NSDictionary *data = @{ + CBAdvertisementDataLocalNameKey : @"dGVzdA", + CBAdvertisementDataServiceUUIDsKey : @[ serviceUUID ], + }; + NSDictionary *actual = [medium decodeAdvertisementData:data]; + + XCTAssertEqualObjects(expected, actual); +} + +- (void)testDecodeInvalidAdvertisementData { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + CBUUID *serviceUUID = [CBUUID UUIDWithString:kServiceUUID]; + + NSDictionary *data = @{ + CBAdvertisementDataLocalNameKey : @"!@#$", + CBAdvertisementDataServiceUUIDsKey : @[ serviceUUID ], + }; + NSDictionary *actual = [medium decodeAdvertisementData:data]; + + XCTAssertEqualObjects(@{}, actual); +} + +- (void)testDecodeEmptyAdvertisementData { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + + NSDictionary *actual = [medium decodeAdvertisementData:@{}]; + + XCTAssertEqualObjects(@{}, actual); +} + +#pragma mark - Start GATT Server + +- (void)testStartGATTServer { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + XCTestExpectation *expectation = + [[XCTestExpectation alloc] initWithDescription:@"Start GATT server."]; + + [medium startGATTServerWithCompletionHandler:^(GNCBLEGATTServer *server, NSError *error) { + XCTAssertNotNil(server); + XCTAssertNil(error); + [expectation fulfill]; + }]; + + [self waitForExpectations:@[ expectation ] timeout:3]; +} + +#pragma mark - Start Advertising + +- (void)testStartAdvertising { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + XCTestExpectation *expectation = + [[XCTestExpectation alloc] initWithDescription:@"Start advertising."]; + + // Start advertising is fully covered with @c GNCBLEGATTServer tests. We are passing invalid + // advertising data here so we can test code paths relevant to @c GNCBLEMedium, but bail early + // enough to avoid making actual CoreBluetooth calls. + [medium startAdvertisingData:@{} + completionHandler:^(NSError *error) { + XCTAssertNotNil(error); + [expectation fulfill]; + }]; + + [self waitForExpectations:@[ expectation ] timeout:3]; +} + +#pragma mark - Connect + +- (void)testSuccessfulConnect { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + XCTestExpectation *expectation = [[XCTestExpectation alloc] initWithDescription:@"Connect."]; + + [medium connectToGATTServerForPeripheral:[[GNCFakePeripheral alloc] init] + disconnectionHandler:nil + completionHandler:^(GNCBLEGATTClient *client, NSError *error) { + XCTAssertNotNil(client); + XCTAssertNil(error); + [expectation fulfill]; + }]; + + [self waitForExpectations:@[ expectation ] timeout:3]; +} + +- (void)testFailedConnect { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + XCTestExpectation *expectation = [[XCTestExpectation alloc] initWithDescription:@"Connect."]; + + fakeCentralManager.didFailToConnectPeripheralError = [NSError errorWithDomain:@"fake" + code:0 + userInfo:nil]; + + [medium connectToGATTServerForPeripheral:[[GNCFakePeripheral alloc] init] + disconnectionHandler:nil + completionHandler:^(GNCBLEGATTClient *client, NSError *error) { + XCTAssertNil(client); + XCTAssertNotNil(error); + [expectation fulfill]; + }]; + + [self waitForExpectations:@[ expectation ] timeout:3]; +} + +- (void)testDisconnect { + GNCFakeCentralManager *fakeCentralManager = [[GNCFakeCentralManager alloc] init]; + GNCBLEMedium *medium = [[GNCBLEMedium alloc] initWithCentralManager:fakeCentralManager queue:nil]; + XCTestExpectation *connectExpectation = + [[XCTestExpectation alloc] initWithDescription:@"Connect."]; + XCTestExpectation *disconnectExpectation = + [[XCTestExpectation alloc] initWithDescription:@"Disconnect."]; + + GNCFakePeripheral *peripheral = [[GNCFakePeripheral alloc] init]; + + [medium connectToGATTServerForPeripheral:peripheral + disconnectionHandler:^() { + [disconnectExpectation fulfill]; + } + completionHandler:^(GNCBLEGATTClient *client, NSError *error) { + XCTAssertNotNil(client); + XCTAssertNil(error); + [connectExpectation fulfill]; + }]; + + [self waitForExpectations:@[ connectExpectation ] timeout:3]; + + [fakeCentralManager simulateCentralManagerDidDisconnectPeripheral:peripheral]; + + [self waitForExpectations:@[ disconnectExpectation ] timeout:3]; +} + +@end diff --git a/internal/platform/implementation/apple/Tests/GNCFakeCentralManager.h b/internal/platform/implementation/apple/Tests/GNCFakeCentralManager.h new file mode 100644 index 00000000..2e2c15e5 --- /dev/null +++ b/internal/platform/implementation/apple/Tests/GNCFakeCentralManager.h @@ -0,0 +1,70 @@ +// 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. + +#import +#import + +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCCentralManager.h" + +NS_ASSUME_NONNULL_BEGIN + +/** A fake implementation of @c GNCCentralManager to inject for testing. */ +@interface GNCFakeCentralManager : NSObject + +/** The list of services being scanned for. */ +@property(nonatomic, nullable, readonly) NSArray *serviceUUIDs; + +/** + * Similates a @c connectPeripheral:options: error. + * + * Setting this error to a value other than @c nil will simulate a failure when calling + * @c connectPeripheral:options: and will call the + * @c gnc_centralManager:didFailToConnectPeripheral:error: delegate method with the provided error. + */ +@property(nonatomic, nullable, readwrite) NSError *didFailToConnectPeripheralError; + +/** + * Simulates a state update event. + * + * Updates the central manager state to the provided value and calls the + * @c gnc_centralManagerDidUpdateState: delegate method. + * + * @param fakeState The new state to transition to. + */ +- (void)simulateCentralManagerDidUpdateState:(CBManagerState)fakeState; + +/** + * Simulates a peripheral discovery event. + * + * Calls the @c gnc_centralManager:didDiscoverPeripheral:advertisementData:RSSI: delegate method. + * + * @param peripheral The discovered peripheral. + * @param peripheral A dictionary containing any advertisement data. + */ +- (void)simulateCentralManagerDidDiscoverPeripheral:(id)peripheral + advertisementData: + (NSDictionary *)advertisementData; + +/** + * Simulates a peripheral disconnection event. + * + * Calls the @c gnc_centralManager:didDisconnectPeripheral:error: delegate method. + * + * @param peripheral The now-disconnected peripheral. + */ +- (void)simulateCentralManagerDidDisconnectPeripheral:(id)peripheral; + +@end + +NS_ASSUME_NONNULL_END diff --git a/internal/platform/implementation/apple/Tests/GNCFakeCentralManager.m b/internal/platform/implementation/apple/Tests/GNCFakeCentralManager.m new file mode 100644 index 00000000..dc080f7e --- /dev/null +++ b/internal/platform/implementation/apple/Tests/GNCFakeCentralManager.m @@ -0,0 +1,86 @@ +// 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. + +#import "internal/platform/implementation/apple/Tests/GNCFakeCentralManager.h" + +#import +#import +#import + +#import "internal/platform/implementation/apple/Mediums/BLEv2/GNCCentralManager.h" +#import "internal/platform/implementation/apple/Tests/GNCFakePeripheral.h" + +@implementation GNCFakeCentralManager { + CBManagerState _state; + NSArray *_serviceUUIDs; +} + +@synthesize centralDelegate; + +- (instancetype)init { + self = [super init]; + if (self) { + _state = CBManagerStateUnknown; + } + return self; +} + +- (CBManagerState)state { + return _state; +} + +- (void)scanForPeripheralsWithServices:(nullable NSArray *)serviceUUIDs + options:(nullable NSDictionary *)options { + _serviceUUIDs = serviceUUIDs; +} + +- (void)connectPeripheral:(id)peripheral + options:(nullable NSDictionary *)options { + if (_didFailToConnectPeripheralError) { + [centralDelegate gnc_centralManager:self + didFailToConnectPeripheral:peripheral + error:_didFailToConnectPeripheralError]; + return; + } + [centralDelegate gnc_centralManager:self didConnectPeripheral:peripheral]; +} + +- (void)stopScan { +} + +#pragma mark - Testing Helpers + +- (NSArray *)serviceUUIDs { + return _serviceUUIDs; +} + +- (void)simulateCentralManagerDidUpdateState:(CBManagerState)fakeState { + _state = fakeState; + [centralDelegate gnc_centralManagerDidUpdateState:self]; +} + +- (void)simulateCentralManagerDidDiscoverPeripheral:(id)peripheral + advertisementData: + (NSDictionary *)advertisementData { + [centralDelegate gnc_centralManager:self + didDiscoverPeripheral:peripheral + advertisementData:advertisementData + RSSI:[NSNumber numberWithInt:0]]; +} + +- (void)simulateCentralManagerDidDisconnectPeripheral:(id)peripheral { + [centralDelegate gnc_centralManager:self didDisconnectPeripheral:peripheral error:nil]; +} + +@end diff --git a/internal/platform/implementation/apple/Tests/GNCFakePeripheral.m b/internal/platform/implementation/apple/Tests/GNCFakePeripheral.m index 1fa97973..ebed3f24 100644 --- a/internal/platform/implementation/apple/Tests/GNCFakePeripheral.m +++ b/internal/platform/implementation/apple/Tests/GNCFakePeripheral.m @@ -38,6 +38,7 @@ NS_ASSUME_NONNULL_BEGIN @implementation GNCFakePeripheral { NSMutableArray *_services; + NSUUID *_identifier; } @synthesize peripheralDelegate; @@ -46,10 +47,15 @@ NS_ASSUME_NONNULL_BEGIN self = [super init]; if (self) { _services = [[NSMutableArray alloc] init]; + _identifier = [[NSUUID alloc] init]; } return self; } +- (NSUUID *)identifier { + return _identifier; +} + - (nullable NSArray *)services { return _services; } diff --git a/internal/platform/implementation/apple/Tests/NSData+GNCWebSafeBase64Test.m b/internal/platform/implementation/apple/Tests/NSData+GNCWebSafeBase64Test.m index ec1c14b6..8310962c 100644 --- a/internal/platform/implementation/apple/Tests/NSData+GNCWebSafeBase64Test.m +++ b/internal/platform/implementation/apple/Tests/NSData+GNCWebSafeBase64Test.m @@ -25,7 +25,7 @@ - (void)testEncodingWithPadding { NSString *expected = @"AQ"; NSData *data = [[NSData alloc] initWithBase64EncodedString:@"AQ==" options:0]; - NSString *actual = [data webSafebase64EncodedString]; + NSString *actual = [data webSafeBase64EncodedString]; XCTAssertEqualObjects(expected, actual); } @@ -35,8 +35,58 @@ [[NSData alloc] initWithBase64EncodedString: @"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" options:0]; - NSString *actual = [data webSafebase64EncodedString]; + NSString *actual = [data webSafeBase64EncodedString]; XCTAssertEqualObjects(expected, actual); } +- (void)testDecodingWithoutPadding { + NSData *expected = [[NSData alloc] initWithBase64EncodedString:@"AQ==" options:0]; + NSData *actual = [[NSData alloc] initWithWebSafeBase64EncodedString:@"AQ"]; + XCTAssertNotNil(actual); + XCTAssertEqualObjects(expected, actual); +} + +- (void)testDecodingNoPad { + NSData *expected = [[NSData alloc] initWithBase64EncodedString:@"aaaa" options:0]; + NSData *actual = [[NSData alloc] initWithWebSafeBase64EncodedString:@"aaaa"]; + XCTAssertNotNil(actual); + XCTAssertEqualObjects(expected, actual); +} + +- (void)testDecoding1Pad { + NSData *expected = [[NSData alloc] initWithBase64EncodedString:@"aaa=" options:0]; + NSData *actual = [[NSData alloc] initWithWebSafeBase64EncodedString:@"aaa"]; + XCTAssertNotNil(actual); + XCTAssertEqualObjects(expected, actual); +} + +- (void)testDecoding2Pad { + NSData *expected = [[NSData alloc] initWithBase64EncodedString:@"aa==" options:0]; + NSData *actual = [[NSData alloc] initWithWebSafeBase64EncodedString:@"aa"]; + XCTAssertNotNil(actual); + XCTAssertEqualObjects(expected, actual); +} + +- (void)testDecodingSingleCharacterInLastQuadruple { + NSData *actual = [[NSData alloc] initWithWebSafeBase64EncodedString:@"aaaab"]; + XCTAssertNil(actual); +} + +- (void)testDecodingWithAllValidCharacters { + NSData *expected = + [[NSData alloc] initWithBase64EncodedString: + @"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + options:0]; + NSData *actual = + [[NSData alloc] initWithWebSafeBase64EncodedString: + @"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"]; + XCTAssertNotNil(actual); + XCTAssertEqualObjects(expected, actual); +} + +- (void)testDecodingWithIllegalCharacters { + NSData *actual = [[NSData alloc] initWithWebSafeBase64EncodedString:@"@#$^&*()"]; + XCTAssertNil(actual); +} + @end