blob: 572ebf46f27701639bc32019d875a66427227579 [file] [log] [blame]
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/common/physical_web/physical_web_scanner.h"
#import <CoreBluetooth/CoreBluetooth.h>
#include <string>
#include <vector>
#import "base/ios/weak_nsobject.h"
#include "base/logging.h"
#import "base/mac/scoped_nsobject.h"
#include "base/memory/ptr_util.h"
#include "base/strings/sys_string_conversions.h"
#include "base/values.h"
#include "components/physical_web/data_source/physical_web_data_source.h"
#include "device/bluetooth/uribeacon/uri_encoder.h"
#import "ios/chrome/common/physical_web/physical_web_device.h"
#import "ios/chrome/common/physical_web/physical_web_request.h"
#import "ios/chrome/common/physical_web/physical_web_types.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
namespace {
NSString* const kUriBeaconServiceUUID = @"FED8";
NSString* const kEddystoneBeaconServiceUUID = @"FEAA";
// The length of time in seconds since a URL was last seen before it should be
// considered lost (ie, no longer nearby).
const NSTimeInterval kLostThresholdSeconds = 15.0;
// The time interval in seconds between checks for lost URLs.
const NSTimeInterval kUpdateIntervalSeconds = 6.0;
enum BeaconType {
BEACON_TYPE_NONE,
BEACON_TYPE_URIBEACON,
BEACON_TYPE_EDDYSTONE,
};
} // namespace
@interface PhysicalWebScanner ()<CBCentralManagerDelegate>
// Decodes the UriBeacon information in the given |data| and a beacon |type| to
// return an unresolved PhysicalWebDevice instance. It also stores the given
// |rssi| in the result.
+ (PhysicalWebDevice*)newDeviceFromData:(NSData*)data
rssi:(int)rssi
type:(BeaconType)type;
// Starts the CoreBluetooth scanner when the bluetooth is powered on and starts
// the update timer.
- (void)reallyStart;
// Stops the CoreBluetooth scanner and update timer.
- (void)reallyStop;
// Timer callback to check for lost URLs based on the elapsed time since they
// were last seen.
- (void)onUpdateTimeElapsed:(NSTimer*)timer;
// Requests metadata of a device if the same URL has not been requested before.
- (void)requestMetadataForDevice:(PhysicalWebDevice*)device;
// Returns the beacon type given the advertisement data.
+ (BeaconType)beaconTypeForAdvertisementData:(NSDictionary*)advertisementData;
@end
@implementation PhysicalWebScanner {
// Delegate that will be notified when the list of devices change.
id<PhysicalWebScannerDelegate> delegate_;
// The value of |started_| is YES when the scanner has been started and NO
// when it's been stopped. The initial value is NO.
BOOL started_;
// The value is valid when the scanner has been started. If bluetooth is not
// powered on, the value is YES, if it's powered on and the CoreBluetooth
// scanner has started, the value is NO.
BOOL pendingStart_;
// List of PhysicalWebRequest that we're waiting the response from.
base::scoped_nsobject<NSMutableArray> pendingRequests_;
// List of resolved PhysicalWebDevice.
base::scoped_nsobject<NSMutableArray> devices_;
// List of URLs that have been resolved or have a pending resolution from a
// PhysicalWebRequest.
base::scoped_nsobject<NSMutableSet> devicesUrls_;
// List of final URLs that have been resolved. This set will help us
// deduplicate the final URLs.
base::scoped_nsobject<NSMutableSet> finalUrls_;
// CoreBluetooth scanner.
base::scoped_nsobject<CBCentralManager> centralManager_;
// When YES, we will notify the delegate if a previously nearby URL is lost
// and remove it from the list of nearby devices.
BOOL onLostDetectionEnabled_;
// The value is YES if network requests can be sent.
BOOL networkRequestEnabled_;
// List of unresolved PhysicalWebDevice when network requests are not enabled.
base::scoped_nsobject<NSMutableArray> unresolvedDevices_;
// A repeating timer to check for lost URLs. If the elapsed time since an URL
// was last seen exceeds a threshold, the URL is considered lost.
base::scoped_nsobject<NSTimer> updateTimer_;
}
@synthesize onLostDetectionEnabled = onLostDetectionEnabled_;
@synthesize networkRequestEnabled = networkRequestEnabled_;
- (instancetype)initWithDelegate:(id<PhysicalWebScannerDelegate>)delegate {
self = [super init];
if (self) {
delegate_ = delegate;
devices_.reset([[NSMutableArray alloc] init]);
devicesUrls_.reset([[NSMutableSet alloc] init]);
finalUrls_.reset([[NSMutableSet alloc] init]);
pendingRequests_.reset([[NSMutableArray alloc] init]);
centralManager_.reset([[CBCentralManager alloc]
initWithDelegate:self
queue:dispatch_get_main_queue()
options:@{
CBCentralManagerOptionShowPowerAlertKey : @NO
}]);
unresolvedDevices_.reset([[NSMutableArray alloc] init]);
}
return self;
}
- (instancetype)init {
NOTREACHED();
return nil;
}
- (void)dealloc {
[centralManager_ setDelegate:nil];
centralManager_.reset();
if (updateTimer_.get()) {
[updateTimer_ invalidate];
updateTimer_.reset();
}
}
- (void)start {
[self stop];
[finalUrls_ removeAllObjects];
[devicesUrls_ removeAllObjects];
[devices_ removeAllObjects];
started_ = YES;
if ([self bluetoothEnabled])
[self reallyStart];
else
pendingStart_ = YES;
}
- (void)stop {
if (!started_)
return;
for (PhysicalWebRequest* request in pendingRequests_.get()) {
[request cancel];
}
[pendingRequests_ removeAllObjects];
if (!pendingStart_ && [self bluetoothEnabled]) {
[self reallyStop];
}
pendingStart_ = NO;
started_ = NO;
}
- (NSArray*)devices {
return [devices_ sortedArrayUsingComparator:^(id obj1, id obj2) {
PhysicalWebDevice* device1 = obj1;
PhysicalWebDevice* device2 = obj2;
// Sorts in ascending order.
if ([device1 rank] > [device2 rank]) {
return NSOrderedDescending;
}
if ([device1 rank] < [device2 rank]) {
return NSOrderedAscending;
}
return NSOrderedSame;
}];
}
- (std::unique_ptr<base::ListValue>)metadata {
auto metadataList = base::MakeUnique<base::ListValue>();
for (PhysicalWebDevice* device in [self devices]) {
std::string scannedUrl =
base::SysNSStringToUTF8([[device requestURL] absoluteString]);
std::string resolvedUrl =
base::SysNSStringToUTF8([[device url] absoluteString]);
std::string icon = base::SysNSStringToUTF8([[device icon] absoluteString]);
std::string title = base::SysNSStringToUTF8([device title]);
std::string description = base::SysNSStringToUTF8([device description]);
auto metadataItem = base::MakeUnique<base::DictionaryValue>();
metadataItem->SetString(kPhysicalWebScannedUrlKey, scannedUrl);
metadataItem->SetString(kPhysicalWebResolvedUrlKey, resolvedUrl);
metadataItem->SetString(kPhysicalWebIconUrlKey, icon);
metadataItem->SetString(kPhysicalWebTitleKey, title);
metadataItem->SetString(kPhysicalWebDescriptionKey, description);
metadataList->Append(std::move(metadataItem));
}
return metadataList;
}
- (void)setNetworkRequestEnabled:(BOOL)enabled {
if (networkRequestEnabled_ == enabled) {
return;
}
networkRequestEnabled_ = enabled;
if (!networkRequestEnabled_)
return;
// Sends the pending requests.
for (PhysicalWebDevice* device in unresolvedDevices_.get()) {
[self requestMetadataForDevice:device];
}
[unresolvedDevices_ removeAllObjects];
}
- (void)setOnLostDetectionEnabled:(BOOL)enabled {
if (enabled == onLostDetectionEnabled_) {
return;
}
onLostDetectionEnabled_ = enabled;
if (started_) {
[self start];
}
}
- (int)unresolvedBeaconsCount {
return [unresolvedDevices_ count];
}
- (BOOL)bluetoothEnabled {
// TODO(crbug.com/619982): The CBManager base class appears to still be in
// flux. Unwind this #ifdef once the APIs settle.
#if defined(__IPHONE_10_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_10_0
return [centralManager_ state] == CBManagerStatePoweredOn;
#else
return [centralManager_ state] == CBCentralManagerStatePoweredOn;
#endif
}
- (void)reallyStart {
pendingStart_ = NO;
if (updateTimer_.get()) {
[updateTimer_ invalidate];
updateTimer_.reset();
}
NSArray* serviceUUIDs = @[
[CBUUID UUIDWithString:kUriBeaconServiceUUID],
[CBUUID UUIDWithString:kEddystoneBeaconServiceUUID]
];
if (onLostDetectionEnabled_) {
// Register a repeating timer to periodically check for lost URLs.
updateTimer_.reset([NSTimer
scheduledTimerWithTimeInterval:kUpdateIntervalSeconds
target:self
selector:@selector(onUpdateTimeElapsed:)
userInfo:nil
repeats:YES]);
}
[centralManager_ scanForPeripheralsWithServices:serviceUUIDs options:nil];
}
- (void)reallyStop {
if (updateTimer_.get()) {
[updateTimer_ invalidate];
updateTimer_.reset();
}
[centralManager_ stopScan];
}
- (void)onUpdateTimeElapsed:(NSTimer*)timer {
NSDate* now = [NSDate date];
NSMutableArray* lostDevices = [NSMutableArray array];
NSMutableArray* lostUnresolvedDevices = [NSMutableArray array];
NSMutableArray* lostScannedUrls = [NSMutableArray array];
for (PhysicalWebDevice* device in devices_.get()) {
NSDate* scanTimestamp = [device scanTimestamp];
NSTimeInterval elapsedSeconds = [now timeIntervalSinceDate:scanTimestamp];
if (elapsedSeconds > kLostThresholdSeconds) {
[lostDevices addObject:device];
[lostScannedUrls addObject:[device requestURL]];
[devicesUrls_ removeObject:[device requestURL]];
[finalUrls_ removeObject:[device url]];
}
}
for (PhysicalWebDevice* device in unresolvedDevices_.get()) {
NSDate* scanTimestamp = [device scanTimestamp];
NSTimeInterval elapsedSeconds = [now timeIntervalSinceDate:scanTimestamp];
if (elapsedSeconds > kLostThresholdSeconds) {
[lostUnresolvedDevices addObject:device];
[lostScannedUrls addObject:[device requestURL]];
[devicesUrls_ removeObject:[device requestURL]];
}
}
NSMutableArray* requestsToRemove = [NSMutableArray array];
for (PhysicalWebRequest* request in pendingRequests_.get()) {
if ([lostScannedUrls containsObject:[request requestURL]]) {
[request cancel];
[requestsToRemove addObject:request];
}
}
[devices_ removeObjectsInArray:lostDevices];
[unresolvedDevices_ removeObjectsInArray:lostUnresolvedDevices];
[pendingRequests_ removeObjectsInArray:requestsToRemove];
if ([lostDevices count]) {
[delegate_ scannerUpdatedDevices:self];
}
// TODO(crbug.com/657056): Remove this workaround when radar is fixed.
// For unknown reasons, when scanning for longer periods (on the order of
// minutes), the scanner is less reliable at detecting all nearby URLs. As a
// workaround, we restart the scanner each time we check for lost URLs.
NSArray* serviceUUIDs = @[
[CBUUID UUIDWithString:kUriBeaconServiceUUID],
[CBUUID UUIDWithString:kEddystoneBeaconServiceUUID]
];
[centralManager_ stopScan];
[centralManager_ scanForPeripheralsWithServices:serviceUUIDs options:nil];
}
#pragma mark -
#pragma mark CBCentralManagerDelegate methods
- (void)centralManagerDidUpdateState:(CBCentralManager*)central {
if ([self bluetoothEnabled]) {
if (pendingStart_)
[self reallyStart];
} else {
if (started_ && !pendingStart_) {
pendingStart_ = YES;
[self reallyStop];
}
}
[delegate_ scannerBluetoothStatusUpdated:self];
}
+ (BeaconType)beaconTypeForAdvertisementData:(NSDictionary*)advertisementData {
NSDictionary* serviceData =
[advertisementData objectForKey:CBAdvertisementDataServiceDataKey];
if ([serviceData objectForKey:[CBUUID UUIDWithString:kUriBeaconServiceUUID]])
return BEACON_TYPE_URIBEACON;
if ([serviceData
objectForKey:[CBUUID UUIDWithString:kEddystoneBeaconServiceUUID]])
return BEACON_TYPE_EDDYSTONE;
return BEACON_TYPE_NONE;
}
- (void)centralManager:(CBCentralManager*)central
didDiscoverPeripheral:(CBPeripheral*)peripheral
advertisementData:(NSDictionary*)advertisementData
RSSI:(NSNumber*)RSSI {
BeaconType type =
[PhysicalWebScanner beaconTypeForAdvertisementData:advertisementData];
if (type == BEACON_TYPE_NONE)
return;
NSDictionary* serviceData =
[advertisementData objectForKey:CBAdvertisementDataServiceDataKey];
NSData* data = nil;
switch (type) {
case BEACON_TYPE_URIBEACON:
data = [serviceData
objectForKey:[CBUUID UUIDWithString:kUriBeaconServiceUUID]];
break;
case BEACON_TYPE_EDDYSTONE:
data = [serviceData
objectForKey:[CBUUID UUIDWithString:kEddystoneBeaconServiceUUID]];
break;
default:
// Do nothing.
break;
}
DCHECK(data);
base::scoped_nsobject<PhysicalWebDevice> device([PhysicalWebScanner
newDeviceFromData:data
rssi:[RSSI intValue]
type:type]);
// Skip if the data couldn't be parsed.
if (!device.get())
return;
// If the URL has already been seen, update its timestamp.
if ([devicesUrls_ containsObject:[device requestURL]]) {
for (PhysicalWebDevice* unresolvedDevice in unresolvedDevices_.get()) {
if ([[unresolvedDevice requestURL] isEqual:[device requestURL]]) {
[unresolvedDevice setScanTimestamp:[NSDate date]];
return;
}
}
for (PhysicalWebDevice* resolvedDevice in devices_.get()) {
if ([[resolvedDevice requestURL] isEqual:[device requestURL]]) {
[resolvedDevice setScanTimestamp:[NSDate date]];
break;
}
}
return;
}
[device setScanTimestamp:[NSDate date]];
[devicesUrls_ addObject:[device requestURL]];
if (networkRequestEnabled_) {
[self requestMetadataForDevice:device];
} else {
[unresolvedDevices_ addObject:device];
[delegate_ scannerUpdatedDevices:self];
}
}
#pragma mark -
#pragma mark UriBeacon resolution
+ (PhysicalWebDevice*)newDeviceFromData:(NSData*)data
rssi:(int)rssi
type:(BeaconType)type {
// No UriBeacon service data.
if (!data)
return nil;
// UriBeacon service data too small.
if ([data length] <= 2)
return nil;
const uint8_t* bytes = static_cast<const uint8_t*>([data bytes]);
if (type == BEACON_TYPE_EDDYSTONE) {
// The packet type is encoded in the high-order 4 bits.
// Returns if it's not an Eddystone-URL.
if ((bytes[0] & 0xf0) != 0x10)
return nil;
}
// - transmit power is at offset 1
// TX Power in the UriBeacon advertising packet is the received power at 0
// meters. The Transmit Power Level represents the transmit power level in
// dBm, and the value ranges from -100 dBm to +20 dBm to a resolution of 1
// dBm.
int transmitPower = static_cast<char>(bytes[1]);
// - scheme and URL are at offset 2.
std::vector<uint8_t> encodedURI(&bytes[2], &bytes[[data length]]);
std::string utf8URI;
device::DecodeUriBeaconUri(encodedURI, utf8URI);
NSString* uriString = base::SysUTF8ToNSString(utf8URI);
NSURL* url = [NSURL URLWithString:uriString];
// Ensure URL is valid.
if (!url)
return nil;
return [[PhysicalWebDevice alloc] initWithURL:url
requestURL:url
icon:nil
title:nil
description:nil
transmitPower:transmitPower
rssi:rssi
rank:physical_web::kMaxRank
scanTimestamp:[NSDate date]];
}
- (void)requestMetadataForDevice:(PhysicalWebDevice*)device {
base::scoped_nsobject<PhysicalWebRequest> request(
[[PhysicalWebRequest alloc] initWithDevice:device]);
PhysicalWebRequest* strongRequest = request.get();
[pendingRequests_ addObject:strongRequest];
base::WeakNSObject<PhysicalWebScanner> weakSelf(self);
[request start:^(PhysicalWebDevice* device, NSError* error) {
base::scoped_nsobject<PhysicalWebScanner> strongSelf(weakSelf);
if (!strongSelf) {
return;
}
// ignore if there's an error.
if (!error) {
if (![strongSelf.get()->finalUrls_ containsObject:[device url]]) {
[strongSelf.get()->devices_ addObject:device];
[strongSelf.get()->delegate_ scannerUpdatedDevices:weakSelf];
[strongSelf.get()->finalUrls_ addObject:[device url]];
}
}
[strongSelf.get()->pendingRequests_ removeObject:strongRequest];
}];
}
@end