// Copyright 2016-present 650 Industries. All rights reserved.
#import <EXLocation/EXLocation.h>
#import <EXLocation/EXLocationDelegate.h>
#import <EXLocation/EXLocationTaskConsumer.h>
#import <EXLocation/EXGeofencingTaskConsumer.h>
#import <CoreLocation/CLLocationManager.h>
#import <CoreLocation/CLLocationManagerDelegate.h>
#import <CoreLocation/CLHeading.h>
#import <CoreLocation/CLGeocoder.h>
#import <CoreLocation/CLPlacemark.h>
#import <CoreLocation/CLError.h>
#import <CoreLocation/CLCircularRegion.h>
#import <UMCore/UMEventEmitterService.h>
#import <UMCore/UMAppLifecycleService.h>
#import <UMPermissionsInterface/UMPermissionsInterface.h>
#import <UMTaskManagerInterface/UMTaskManagerInterface.h>
NS_ASSUME_NONNULL_BEGIN
NSString * const EXLocationChangedEventName = @"Expo.locationChanged";
NSString * const EXHeadingChangedEventName = @"Expo.headingChanged";
@interface EXLocation ()
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, EXLocationDelegate*> *delegates;
@property (nonatomic, strong) NSMutableSet<EXLocationDelegate *> *retainedDelegates;
@property (nonatomic, assign, getter=isPaused) BOOL paused;
@property (nonatomic, weak) id<UMEventEmitterService> eventEmitter;
@property (nonatomic, weak) id<UMPermissionsInterface> permissions;
@property (nonatomic, weak) id<UMAppLifecycleService> lifecycleService;
@property (nonatomic, weak) id<UMTaskManagerInterface> 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
{
if (_lifecycleService) {
[_lifecycleService unregisterAppLifecycleListener:self];
}
_eventEmitter = [moduleRegistry getModuleImplementingProtocol:@protocol(UMEventEmitterService)];
_permissions = [moduleRegistry getModuleImplementingProtocol:@protocol(UMPermissionsInterface)];
_lifecycleService = [moduleRegistry getModuleImplementingProtocol:@protocol(UMAppLifecycleService)];
_tasksManager = [moduleRegistry getModuleImplementingProtocol:@protocol(UMTaskManagerInterface)];
if (_lifecycleService) {
[_lifecycleService registerAppLifecycleListener:self];
}
}
- (dispatch_queue_t)methodQueue
{
// Location managers must be created on the main thread
return dispatch_get_main_queue();
}
# pragma mark - UMEventEmitter
- (NSArray<NSString *> *)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<CLLocation *> * _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<CLLocation *> *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]);
}
// Watch method for getting compass updates
UM_EXPORT_METHOD_AS(watchDeviceHeading,
watchHeadingWithWatchId:(nonnull NSNumber *)watchId
resolve:(UMPromiseResolveBlock)resolve
reject:(UMPromiseRejectBlock)reject) {
if (![_permissions hasGrantedPermission:@"location"]) {
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)
{
if ([self isPaused]) {
return;
}
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)
{
if ([self isPaused]) {
return;
}
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": placemark.locality ?: [NSNull null],
@"street": placemark.thoroughfare ?: [NSNull null],
@"region": placemark.administrativeArea ?: [NSNull null],
@"country": placemark.country ?: [NSNull null],
@"postalCode": placemark.postalCode ?: [NSNull null],
@"name": placemark.name ?: [NSNull null],
@"isoCountryCode": placemark.ISOcountryCode ?: [NSNull null],
};
[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(requestPermissionsAsync,
requestPermissionsResolver:(UMPromiseResolveBlock)resolve
rejecter:(UMPromiseRejectBlock)reject)
{
if (!_permissions) {
return reject(@"E_NO_PERMISSIONS", @"Permissions module not found. Are you sure that Expo modules are properly linked?", nil);
}
[_permissions askForPermission:@"location" withResult:resolve withRejecter: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:(NSDictionary *)options
{
CLLocationManager *locMgr = [[CLLocationManager alloc] init];
locMgr.distanceFilter = options[@"distanceInterval"] ? [options[@"distanceInterval"] doubleValue] ?: kCLDistanceFilterNone : kCLLocationAccuracyHundredMeters;
locMgr.desiredAccuracy = [options[@"enableHighAccuracy"] boolValue] ? kCLLocationAccuracyBest : kCLLocationAccuracyHundredMeters;
locMgr.allowsBackgroundLocationUpdates = NO;
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 (![_permissions hasGrantedPermission:@"location"]) {
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;
}
# pragma mark - UMAppLifecycleListener
- (void)onAppForegrounded
{
if ([self isPaused]) {
[self setPaused:NO];
}
}
- (void)onAppBackgrounded
{
if (![self isPaused]) {
[self setPaused:YES];
}
}
@end
NS_ASSUME_NONNULL_END