// Copyright 2016-present 650 Industries. All rights reserved. #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import NS_ASSUME_NONNULL_BEGIN NSString * const EXLocationChangedEventName = @"Expo.locationChanged"; NSString * const EXHeadingChangedEventName = @"Expo.headingChanged"; @interface EXLocation () @property (nonatomic, strong) NSMutableDictionary *delegates; @property (nonatomic, strong) NSMutableSet *retainedDelegates; @property (nonatomic, weak) id eventEmitter; @property (nonatomic, weak) id permissionsManager; @property (nonatomic, weak) id tasksManager; @end @implementation EXLocation UM_EXPORT_MODULE(ExpoLocation); - (instancetype)init { if (self = [super init]) { _delegates = [NSMutableDictionary dictionary]; _retainedDelegates = [NSMutableSet set]; } return self; } - (void)setModuleRegistry:(UMModuleRegistry *)moduleRegistry { _eventEmitter = [moduleRegistry getModuleImplementingProtocol:@protocol(UMEventEmitterService)]; _permissionsManager = [moduleRegistry getModuleImplementingProtocol:@protocol(UMPermissionsInterface)]; [UMPermissionsMethodsDelegate registerRequesters:@[[EXLocationPermissionRequester new]] withPermissionsManager:_permissionsManager]; _tasksManager = [moduleRegistry getModuleImplementingProtocol:@protocol(UMTaskManagerInterface)]; } - (dispatch_queue_t)methodQueue { // Location managers must be created on the main thread return dispatch_get_main_queue(); } # pragma mark - UMEventEmitter - (NSArray *)supportedEvents { return @[EXLocationChangedEventName, EXHeadingChangedEventName]; } - (void)startObserving {} - (void)stopObserving {} # pragma mark - Exported methods UM_EXPORT_METHOD_AS(getProviderStatusAsync, resolver:(UMPromiseResolveBlock)resolve rejecter:(UMPromiseRejectBlock)reject) { resolve(@{ @"locationServicesEnabled": @([CLLocationManager locationServicesEnabled]), @"backgroundModeEnabled": @([_tasksManager hasBackgroundModeEnabled:@"location"]), }); } UM_EXPORT_METHOD_AS(getCurrentPositionAsync, options:(NSDictionary *)options resolver:(UMPromiseResolveBlock)resolve rejecter:(UMPromiseRejectBlock)reject) { if (![self checkPermissions:reject]) { return; } CLLocationManager *locMgr = [self locationManagerWithOptions:options]; __weak typeof(self) weakSelf = self; __block EXLocationDelegate *delegate; delegate = [[EXLocationDelegate alloc] initWithId:nil withLocMgr:locMgr onUpdateLocations:^(NSArray * _Nonnull locations) { if (delegate != nil) { if (locations.lastObject != nil) { resolve([EXLocation exportLocation:locations.lastObject]); } else { reject(@"E_LOCATION_NOT_FOUND", @"Current location not found.", nil); } [weakSelf.retainedDelegates removeObject:delegate]; delegate = nil; } } onUpdateHeadings:nil onError:^(NSError *error) { reject(@"E_LOCATION_UNAVAILABLE", [@"Cannot obtain current location: " stringByAppendingString:error.description], nil); }]; // retain location manager delegate so it will not dealloc until onUpdateLocations gets called [_retainedDelegates addObject:delegate]; locMgr.delegate = delegate; [locMgr requestLocation]; } UM_EXPORT_METHOD_AS(watchPositionImplAsync, watchId:(nonnull NSNumber *)watchId options:(NSDictionary *)options resolver:(UMPromiseResolveBlock)resolve rejecter:(UMPromiseRejectBlock)reject) { if (![self checkPermissions:reject]) { return; } __weak typeof(self) weakSelf = self; CLLocationManager *locMgr = [self locationManagerWithOptions:options]; EXLocationDelegate *delegate = [[EXLocationDelegate alloc] initWithId:watchId withLocMgr:locMgr onUpdateLocations:^(NSArray *locations) { if (locations.lastObject != nil && weakSelf != nil) { __strong typeof(weakSelf) strongSelf = weakSelf; CLLocation *loc = locations.lastObject; NSDictionary *body = @{ @"watchId": watchId, @"location": [EXLocation exportLocation:loc], }; [strongSelf->_eventEmitter sendEventWithName:EXLocationChangedEventName body:body]; } } onUpdateHeadings:nil onError:^(NSError *error) { // TODO: report errors // (ben) error could be (among other things): // - kCLErrorDenied - we should use the same UNAUTHORIZED behavior as elsewhere // - kCLErrorLocationUnknown - we can actually ignore this error and keep tracking // location (I think -- my knowledge might be a few months out of date) }]; _delegates[delegate.watchId] = delegate; locMgr.delegate = delegate; [locMgr startUpdatingLocation]; resolve([NSNull null]); } UM_EXPORT_METHOD_AS(getLastKnownPositionAsync, getLastKnownPositionWithOptions:(NSDictionary *)options resolve:(UMPromiseResolveBlock)resolve rejecter:(UMPromiseRejectBlock)reject) { if (![self checkPermissions:reject]) { return; } CLLocation *location = [[self locationManagerWithOptions:nil] location]; if ([self.class isLocation:location validWithOptions:options]) { resolve([EXLocation exportLocation:location]); } else { resolve([NSNull null]); } } // Watch method for getting compass updates UM_EXPORT_METHOD_AS(watchDeviceHeading, watchHeadingWithWatchId:(nonnull NSNumber *)watchId resolve:(UMPromiseResolveBlock)resolve reject:(UMPromiseRejectBlock)reject) { if (![_permissionsManager hasGrantedPermissionUsingRequesterClass:[EXLocationPermissionRequester class]]) { reject(@"E_LOCATION_UNAUTHORIZED", @"Not authorized to use location services", nil); return; } __weak typeof(self) weakSelf = self; CLLocationManager *locMgr = [[CLLocationManager alloc] init]; locMgr.distanceFilter = kCLDistanceFilterNone; locMgr.desiredAccuracy = kCLLocationAccuracyBest; locMgr.allowsBackgroundLocationUpdates = NO; EXLocationDelegate *delegate = [[EXLocationDelegate alloc] initWithId:watchId withLocMgr:locMgr onUpdateLocations: nil onUpdateHeadings:^(CLHeading *newHeading) { if (newHeading != nil && weakSelf != nil) { __strong typeof(weakSelf) strongSelf = weakSelf; NSNumber *accuracy; // Convert iOS heading accuracy to Android system // 3: high accuracy, 2: medium, 1: low, 0: none if (newHeading.headingAccuracy > 50 || newHeading.headingAccuracy < 0) { accuracy = @(0); } else if (newHeading.headingAccuracy > 35) { accuracy = @(1); } else if (newHeading.headingAccuracy > 20) { accuracy = @(2); } else { accuracy = @(3); } NSDictionary *body = @{@"watchId": watchId, @"heading": @{ @"trueHeading": @(newHeading.trueHeading), @"magHeading": @(newHeading.magneticHeading), @"accuracy": accuracy, }, }; [strongSelf->_eventEmitter sendEventWithName:EXHeadingChangedEventName body:body]; } } onError:^(NSError *error) { // Error getting updates }]; _delegates[delegate.watchId] = delegate; locMgr.delegate = delegate; [locMgr startUpdatingHeading]; resolve([NSNull null]); } UM_EXPORT_METHOD_AS(removeWatchAsync, watchId:(nonnull NSNumber *)watchId resolver:(UMPromiseResolveBlock)resolve rejecter:(UMPromiseRejectBlock)reject) { EXLocationDelegate *delegate = _delegates[watchId]; if (delegate) { // Unsuscribe from both location and heading updates [delegate.locMgr stopUpdatingLocation]; [delegate.locMgr stopUpdatingHeading]; delegate.locMgr.delegate = nil; [_delegates removeObjectForKey:watchId]; } resolve([NSNull null]); } UM_EXPORT_METHOD_AS(geocodeAsync, address:(nonnull NSString *)address resolver:(UMPromiseResolveBlock)resolve rejecter:(UMPromiseRejectBlock)reject) { CLGeocoder *geocoder = [[CLGeocoder alloc] init]; [geocoder geocodeAddressString:address completionHandler:^(NSArray* placemarks, NSError* error){ if (!error) { NSMutableArray *results = [NSMutableArray arrayWithCapacity:placemarks.count]; for (CLPlacemark* placemark in placemarks) { CLLocation *location = placemark.location; [results addObject:@{ @"latitude": @(location.coordinate.latitude), @"longitude": @(location.coordinate.longitude), @"altitude": @(location.altitude), @"accuracy": @(location.horizontalAccuracy), }]; } resolve(results); } else if (error.code == kCLErrorGeocodeFoundNoResult || error.code == kCLErrorGeocodeFoundPartialResult) { resolve(@[]); } else if (error.code == kCLErrorNetwork) { reject(@"E_RATE_EXCEEDED", @"Rate limit exceeded - too many requests", error); } else { reject(@"E_GEOCODING_FAILED", @"Error while geocoding an address", error); } }]; } UM_EXPORT_METHOD_AS(reverseGeocodeAsync, locationMap:(nonnull NSDictionary *)locationMap resolver:(UMPromiseResolveBlock)resolve rejecter:(UMPromiseRejectBlock)reject) { CLGeocoder *geocoder = [[CLGeocoder alloc] init]; CLLocation *location = [[CLLocation alloc] initWithLatitude:[locationMap[@"latitude"] floatValue] longitude:[locationMap[@"longitude"] floatValue]]; [geocoder reverseGeocodeLocation:location completionHandler:^(NSArray* placemarks, NSError* error){ if (!error) { NSMutableArray *results = [NSMutableArray arrayWithCapacity:placemarks.count]; for (CLPlacemark* placemark in placemarks) { NSDictionary *address = @{ @"city": UMNullIfNil(placemark.locality), @"district": UMNullIfNil(placemark.subLocality), @"street": UMNullIfNil(placemark.thoroughfare), @"region": UMNullIfNil(placemark.administrativeArea), @"subregion": UMNullIfNil(placemark.subAdministrativeArea), @"country": UMNullIfNil(placemark.country), @"postalCode": UMNullIfNil(placemark.postalCode), @"name": UMNullIfNil(placemark.name), @"isoCountryCode": UMNullIfNil(placemark.ISOcountryCode), @"timezone": UMNullIfNil(placemark.timeZone.name), }; [results addObject:address]; } resolve(results); } else if (error.code == kCLErrorGeocodeFoundNoResult || error.code == kCLErrorGeocodeFoundPartialResult) { resolve(@[]); } else if (error.code == kCLErrorNetwork) { reject(@"E_RATE_EXCEEDED", @"Rate limit exceeded - too many requests", error); } else { reject(@"E_REVGEOCODING_FAILED", @"Error while reverse-geocoding a location", error); } }]; } UM_EXPORT_METHOD_AS(getPermissionsAsync, getPermissionsAsync:(UMPromiseResolveBlock)resolve rejecter:(UMPromiseRejectBlock)reject) { [UMPermissionsMethodsDelegate getPermissionWithPermissionsManager:_permissionsManager withRequester:[EXLocationPermissionRequester class] resolve:resolve reject:reject]; } UM_EXPORT_METHOD_AS(requestPermissionsAsync, requestPermissionsAsync:(UMPromiseResolveBlock)resolve rejecter:(UMPromiseRejectBlock)reject) { [UMPermissionsMethodsDelegate askForPermissionWithPermissionsManager:_permissionsManager withRequester:[EXLocationPermissionRequester class] resolve:resolve reject:reject]; } UM_EXPORT_METHOD_AS(hasServicesEnabledAsync, hasServicesEnabled:(UMPromiseResolveBlock)resolve reject:(UMPromiseRejectBlock)reject) { BOOL servicesEnabled = [CLLocationManager locationServicesEnabled]; resolve(@(servicesEnabled)); } # pragma mark - Background location UM_EXPORT_METHOD_AS(startLocationUpdatesAsync, startLocationUpdatesForTaskWithName:(nonnull NSString *)taskName withOptions:(nonnull NSDictionary *)options resolve:(UMPromiseResolveBlock)resolve reject:(UMPromiseRejectBlock)reject) { if (![self checkPermissions:reject] || ![self checkTaskManagerExists:reject] || ![self checkBackgroundServices:reject]) { return; } if (![CLLocationManager significantLocationChangeMonitoringAvailable]) { return reject(@"E_SIGNIFICANT_CHANGES_UNAVAILABLE", @"Significant location changes monitoring is not available.", nil); } @try { [_tasksManager registerTaskWithName:taskName consumer:[EXLocationTaskConsumer class] options:options]; } @catch (NSException *e) { return reject(e.name, e.reason, nil); } resolve(nil); } UM_EXPORT_METHOD_AS(stopLocationUpdatesAsync, stopLocationUpdatesForTaskWithName:(NSString *)taskName resolve:(UMPromiseResolveBlock)resolve reject:(UMPromiseRejectBlock)reject) { if (![self checkTaskManagerExists:reject]) { return; } @try { [_tasksManager unregisterTaskWithName:taskName consumerClass:[EXLocationTaskConsumer class]]; } @catch (NSException *e) { return reject(e.name, e.reason, nil); } resolve(nil); } UM_EXPORT_METHOD_AS(hasStartedLocationUpdatesAsync, hasStartedLocationUpdatesForTaskWithName:(nonnull NSString *)taskName resolve:(UMPromiseResolveBlock)resolve reject:(UMPromiseRejectBlock)reject) { if (![self checkTaskManagerExists:reject]) { return; } resolve(@([_tasksManager taskWithName:taskName hasConsumerOfClass:[EXLocationTaskConsumer class]])); } # pragma mark - Geofencing UM_EXPORT_METHOD_AS(startGeofencingAsync, startGeofencingWithTaskName:(nonnull NSString *)taskName withOptions:(nonnull NSDictionary *)options resolve:(UMPromiseResolveBlock)resolve reject:(UMPromiseRejectBlock)reject) { if (![self checkPermissions:reject] || ![self checkTaskManagerExists:reject]) { return; } if (![CLLocationManager isMonitoringAvailableForClass:[CLCircularRegion class]]) { return reject(@"E_GEOFENCING_UNAVAILABLE", @"Geofencing is not available", nil); } @try { [_tasksManager registerTaskWithName:taskName consumer:[EXGeofencingTaskConsumer class] options:options]; } @catch (NSException *e) { return reject(e.name, e.reason, nil); } resolve(nil); } UM_EXPORT_METHOD_AS(stopGeofencingAsync, stopGeofencingWithTaskName:(nonnull NSString *)taskName resolve:(UMPromiseResolveBlock)resolve reject:(UMPromiseRejectBlock)reject) { if (![self checkTaskManagerExists:reject]) { return; } @try { [_tasksManager unregisterTaskWithName:taskName consumerClass:[EXGeofencingTaskConsumer class]]; } @catch (NSException *e) { return reject(e.name, e.reason, nil); } resolve(nil); } UM_EXPORT_METHOD_AS(hasStartedGeofencingAsync, hasStartedGeofencingForTaskWithName:(NSString *)taskName resolve:(UMPromiseResolveBlock)resolve reject:(UMPromiseRejectBlock)reject) { if (![self checkTaskManagerExists:reject]) { return; } resolve(@([_tasksManager taskWithName:taskName hasConsumerOfClass:[EXGeofencingTaskConsumer class]])); } # pragma mark - helpers - (CLLocationManager *)locationManagerWithOptions:(nullable NSDictionary *)options { CLLocationManager *locMgr = [[CLLocationManager alloc] init]; locMgr.allowsBackgroundLocationUpdates = NO; if (options) { locMgr.distanceFilter = options[@"distanceInterval"] ? [options[@"distanceInterval"] doubleValue] ?: kCLDistanceFilterNone : kCLLocationAccuracyHundredMeters; if (options[@"accuracy"]) { EXLocationAccuracy accuracy = [options[@"accuracy"] unsignedIntegerValue] ?: EXLocationAccuracyBalanced; locMgr.desiredAccuracy = [self.class CLLocationAccuracyFromOption:accuracy]; } } return locMgr; } - (BOOL)checkPermissions:(UMPromiseRejectBlock)reject { if (![CLLocationManager locationServicesEnabled]) { reject(@"E_LOCATION_SERVICES_DISABLED", @"Location services are disabled", nil); return NO; } if (![_permissionsManager hasGrantedPermissionUsingRequesterClass:[EXLocationPermissionRequester class]]) { reject(@"E_NO_PERMISSIONS", @"LOCATION permission is required to do this operation.", nil); return NO; } return YES; } - (BOOL)checkTaskManagerExists:(UMPromiseRejectBlock)reject { if (_tasksManager == nil) { reject(@"E_TASKMANAGER_NOT_FOUND", @"`expo-task-manager` module is required to use background services.", nil); return NO; } return YES; } - (BOOL)checkBackgroundServices:(UMPromiseRejectBlock)reject { if (![_tasksManager hasBackgroundModeEnabled:@"location"]) { reject(@"E_BACKGROUND_SERVICES_DISABLED", @"Background Location has not been configured. To enable it, add `location` to `UIBackgroundModes` in Info.plist file.", nil); return NO; } return YES; } # pragma mark - static helpers + (NSDictionary *)exportLocation:(CLLocation *)location { return @{ @"coords": @{ @"latitude": @(location.coordinate.latitude), @"longitude": @(location.coordinate.longitude), @"altitude": @(location.altitude), @"accuracy": @(location.horizontalAccuracy), @"altitudeAccuracy": @(location.verticalAccuracy), @"heading": @(location.course), @"speed": @(location.speed), }, @"timestamp": @([location.timestamp timeIntervalSince1970] * 1000), }; } + (CLLocationAccuracy)CLLocationAccuracyFromOption:(EXLocationAccuracy)accuracy { switch (accuracy) { case EXLocationAccuracyLowest: return kCLLocationAccuracyThreeKilometers; case EXLocationAccuracyLow: return kCLLocationAccuracyKilometer; case EXLocationAccuracyBalanced: return kCLLocationAccuracyHundredMeters; case EXLocationAccuracyHigh: return kCLLocationAccuracyNearestTenMeters; case EXLocationAccuracyHighest: return kCLLocationAccuracyBest; case EXLocationAccuracyBestForNavigation: return kCLLocationAccuracyBestForNavigation; default: return kCLLocationAccuracyHundredMeters; } } + (CLActivityType)CLActivityTypeFromOption:(NSInteger)activityType { if (activityType >= CLActivityTypeOther && activityType <= CLActivityTypeOtherNavigation) { return activityType; } if (@available(iOS 12.0, *)) { if (activityType == CLActivityTypeAirborne) { return activityType; } } return CLActivityTypeOther; } + (BOOL)isLocation:(nullable CLLocation *)location validWithOptions:(nullable NSDictionary *)options { if (location == nil) { return NO; } NSTimeInterval maxAge = options[@"maxAge"] ? [options[@"maxAge"] doubleValue] : DBL_MAX; CLLocationAccuracy requiredAccuracy = options[@"requiredAccuracy"] ? [options[@"requiredAccuracy"] doubleValue] : DBL_MAX; NSTimeInterval timeDiff = -location.timestamp.timeIntervalSinceNow; return location != nil && timeDiff * 1000 <= maxAge && location.horizontalAccuracy <= requiredAccuracy; } @end NS_ASSUME_NONNULL_END