diff --git a/gyp/platform-ios.gypi b/gyp/platform-ios.gypi index e2c840581..dc1c082ed 100644 --- a/gyp/platform-ios.gypi +++ b/gyp/platform-ios.gypi @@ -48,6 +48,8 @@ '../platform/ios/src/MGLMapboxEvents.m', '../platform/ios/src/MGLAPIClient.h', '../platform/ios/src/MGLAPIClient.m', + '../platform/ios/src/MGLLocationManager.h', + '../platform/ios/src/MGLLocationManager.m', '../platform/ios/src/MGLMapView.mm', '../platform/ios/src/MGLAccountManager_Private.h', '../platform/ios/src/MGLAccountManager.m', diff --git a/platform/ios/src/MGLLocationManager.h b/platform/ios/src/MGLLocationManager.h new file mode 100644 index 000000000..ea2380181 --- /dev/null +++ b/platform/ios/src/MGLLocationManager.h @@ -0,0 +1,25 @@ +#import +#import + +@protocol MGLLocationManagerDelegate; + +@interface MGLLocationManager : NSObject + +@property (nonatomic, weak) id delegate; + +- (void)startUpdatingLocation; +- (void)stopUpdatingLocation; + +@end + +@protocol MGLLocationManagerDelegate + +@optional + +- (void)locationManager:(MGLLocationManager *)locationManager didUpdateLocations:(NSArray *)locations; +- (void)locationManagerDidStartLocationUpdates:(MGLLocationManager *)locationManager; +- (void)locationManagerBackgroundLocationUpdatesDidTimeout:(MGLLocationManager *)locationManager; +- (void)locationManagerBackgroundLocationUpdatesDidAutomaticallyPause:(MGLLocationManager *)locationManager; +- (void)locationManagerDidStopLocationUpdates:(MGLLocationManager *)locationManager; + +@end diff --git a/platform/ios/src/MGLLocationManager.m b/platform/ios/src/MGLLocationManager.m new file mode 100644 index 000000000..7a3f8de4f --- /dev/null +++ b/platform/ios/src/MGLLocationManager.m @@ -0,0 +1,159 @@ +#import "MGLLocationManager.h" +#import + +static const NSTimeInterval fiveMinuteTimeInterval = 300.0; +static const NSTimeInterval fiveSecondTimeInterval = 5.0; +static const CLLocationDistance regionRadiusLocationDistance = 300.0; +static NSString * const MGLLocationManagerRegionIdentifier = @"MGLLocationManagerRegionIdentifier.fence.center"; + +@interface MGLLocationManager () + +@property (nonatomic) CLLocationManager *standardLocationManager; +@property (nonatomic) BOOL hostAppHasBackgroundCapability; +@property (nonatomic, getter=isUpdatingLocation) BOOL updatingLocation; +@property (nonatomic) NSDate *backgroundLocationServiceTimeoutAllowedDate; +@property (nonatomic) NSTimer *backgroundLocationServiceTimeoutTimer; + +@end + +@implementation MGLLocationManager + +- (instancetype)init { + self = [super init]; + if (self) { + NSArray *backgroundModes = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"UIBackgroundModes"]; + _hostAppHasBackgroundCapability = [backgroundModes containsObject:@"location"]; + } + return self; +} + +- (void)startUpdatingLocation { + if ([self isUpdatingLocation]) { + return; + } + + [self configurePassiveStandardLocationManager]; + [self startLocationServices]; +} + +- (void)stopUpdatingLocation { + if ([self isUpdatingLocation]) { + [self.standardLocationManager stopUpdatingLocation]; + [self.standardLocationManager stopMonitoringSignificantLocationChanges]; + self.updatingLocation = NO; + if ([self.delegate respondsToSelector:@selector(locationManagerDidStopLocationUpdates:)]) { + [self.delegate locationManagerDidStopLocationUpdates:self]; + } + } +} + +#pragma mark - Utilities + +- (void)configurePassiveStandardLocationManager { + if (!self.standardLocationManager) { + CLLocationManager *standardLocationManager = [[CLLocationManager alloc] init]; + standardLocationManager.delegate = self; + standardLocationManager.desiredAccuracy = kCLLocationAccuracyThreeKilometers; + standardLocationManager.distanceFilter = 1; + self.standardLocationManager = standardLocationManager; + } +} + +- (void)startLocationServices { + if ([CLLocationManager authorizationStatus] == kCLAuthorizationStatusAuthorized || + [CLLocationManager authorizationStatus] == kCLAuthorizationStatusAuthorizedWhenInUse) { + + // If the host app can run in the background with `always` location permissions then allow background + // updates and start the significant location change service and background timeout timer + if (self.hostAppHasBackgroundCapability && [CLLocationManager authorizationStatus] == kCLAuthorizationStatusAuthorized) { + [self.standardLocationManager startMonitoringSignificantLocationChanges]; + [self startBackgroundTimeoutTimer]; + // On iOS 9 and above also allow background location updates + if ([self.standardLocationManager respondsToSelector:@selector(allowsBackgroundLocationUpdates)]) { + self.standardLocationManager.allowsBackgroundLocationUpdates = YES; + } + } + + [self.standardLocationManager startUpdatingLocation]; + self.updatingLocation = YES; + if ([self.delegate respondsToSelector:@selector(locationManagerDidStartLocationUpdates:)]) { + [self.delegate locationManagerDidStartLocationUpdates:self]; + } + } +} + +- (void)timeoutAllowedCheck { + if (self.backgroundLocationServiceTimeoutAllowedDate == nil) { + return; + } + + if ([UIApplication sharedApplication].applicationState == UIApplicationStateActive || + [UIApplication sharedApplication].applicationState == UIApplicationStateInactive ) { + [self startBackgroundTimeoutTimer]; + return; + } + + NSTimeInterval timeIntervalSinceTimeoutAllowed = [[NSDate date] timeIntervalSinceDate:self.backgroundLocationServiceTimeoutAllowedDate]; + if (timeIntervalSinceTimeoutAllowed > 0) { + [self.standardLocationManager stopUpdatingLocation]; + self.backgroundLocationServiceTimeoutAllowedDate = nil; + if ([self.delegate respondsToSelector:@selector(locationManagerBackgroundLocationUpdatesDidTimeout:)]) { + [self.delegate locationManagerBackgroundLocationUpdatesDidTimeout:self]; + } + } +} + +- (void)startBackgroundTimeoutTimer { + [self.backgroundLocationServiceTimeoutTimer invalidate]; + self.backgroundLocationServiceTimeoutAllowedDate = [[NSDate date] dateByAddingTimeInterval:fiveMinuteTimeInterval]; + self.backgroundLocationServiceTimeoutTimer = [NSTimer scheduledTimerWithTimeInterval:fiveSecondTimeInterval target:self selector:@selector(timeoutAllowedCheck) userInfo:nil repeats:YES]; +} + +- (void)establishRegionMonitoringForLocation:(CLLocation *)location { + CLCircularRegion *region = [[CLCircularRegion alloc] initWithCenter:location.coordinate radius:regionRadiusLocationDistance identifier:MGLLocationManagerRegionIdentifier]; + region.notifyOnEntry = NO; + region.notifyOnExit = YES; + [self.standardLocationManager startMonitoringForRegion:region]; +} + +#pragma mark - CLLocationManagerDelegate + +- (void)locationManager:(CLLocationManager *)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status { + switch (status) { + case kCLAuthorizationStatusAuthorized: // Also handles kCLAuthorizationStatusAuthorizedAlways + case kCLAuthorizationStatusAuthorizedWhenInUse: + [self startUpdatingLocation]; + break; + default: + [self stopUpdatingLocation]; + break; + } +} + +- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations { + CLLocation *location = locations.lastObject; + if (location.speed > 0.0) { + [self startBackgroundTimeoutTimer]; + } + if (self.standardLocationManager.monitoredRegions.count == 0 || location.horizontalAccuracy < regionRadiusLocationDistance) { + [self establishRegionMonitoringForLocation:location]; + } + if ([self.delegate respondsToSelector:@selector(locationManager:didUpdateLocations:)]) { + [self.delegate locationManager:self didUpdateLocations:locations]; + } +} + +- (void)locationManager:(CLLocationManager *)manager didExitRegion:(CLRegion *)region { + [self startBackgroundTimeoutTimer]; + [self.standardLocationManager startUpdatingLocation]; +} + +- (void)locationManagerDidPauseLocationUpdates:(CLLocationManager *)manager { + if ([UIApplication sharedApplication].applicationState == UIApplicationStateBackground) { + if ([self.delegate respondsToSelector:@selector(locationManagerBackgroundLocationUpdatesDidAutomaticallyPause:)]) { + [self.delegate locationManagerBackgroundLocationUpdatesDidAutomaticallyPause:self]; + } + } +} + +@end diff --git a/platform/ios/src/MGLMapView.mm b/platform/ios/src/MGLMapView.mm index 78ab23533..458221fd6 100644 --- a/platform/ios/src/MGLMapView.mm +++ b/platform/ios/src/MGLMapView.mm @@ -997,7 +997,6 @@ mbgl::Duration MGLDurationInSeconds(NSTimeInterval duration) self.dormant = NO; [self createGLView]; - [MGLMapboxEvents validate]; self.glSnapshotView.hidden = YES; diff --git a/platform/ios/src/MGLMapboxEvents.h b/platform/ios/src/MGLMapboxEvents.h index 9809bca50..9d90bd967 100644 --- a/platform/ios/src/MGLMapboxEvents.h +++ b/platform/ios/src/MGLMapboxEvents.h @@ -39,17 +39,13 @@ extern NSString *const MGLEventGesturePitchStart; typedef NS_DICTIONARY_OF(NSString *, id) MGLMapboxEventAttributes; typedef NS_MUTABLE_DICTIONARY_OF(NSString *, id) MGLMutableMapboxEventAttributes; -@interface MGLMapboxEvents : NSObject +@interface MGLMapboxEvents : NSObject + (nullable instancetype)sharedManager; // You must call these methods from the main thread. // -+ (void)pauseMetricsCollection; -+ (void)resumeMetricsCollection; + (void)pushEvent:(NSString *)event withAttributes:(MGLMapboxEventAttributes *)attributeDictionary; -+ (void)pushDebugEvent:(NSString *)event withAttributes:(MGLMapboxEventAttributes *)attributeDictionary; -+ (void)validate; + (void)ensureMetricsOptoutExists; + (void)flush; + (BOOL)checkPushEnabled; diff --git a/platform/ios/src/MGLMapboxEvents.m b/platform/ios/src/MGLMapboxEvents.m index 7f4b632e8..9097c5b10 100644 --- a/platform/ios/src/MGLMapboxEvents.m +++ b/platform/ios/src/MGLMapboxEvents.m @@ -6,6 +6,7 @@ #import "NSBundle+MGLAdditions.h" #import "NSException+MGLAdditions.h" #import "MGLAPIClient.h" +#import "MGLLocationManager.h" #include #include @@ -89,15 +90,12 @@ const NSTimeInterval MGLFlushInterval = 60; @end -@interface MGLMapboxEvents () +@interface MGLMapboxEvents () @property (nonatomic) MGLMapboxEventsData *data; @property (nonatomic, copy) NSString *appBundleId; @property (nonatomic, copy) NSString *instanceID; @property (nonatomic, copy) NSString *dateForDebugLogFile; -@property (nonatomic, copy) NSData *digicertCert; -@property (nonatomic, copy) NSData *geoTrustCert; -@property (nonatomic, copy) NSData *testServerCert; @property (nonatomic) NSDateFormatter *rfc3339DateFormatter; @property (nonatomic) MGLAPIClient *apiClient; @property (nonatomic) BOOL usesTestServer; @@ -106,7 +104,7 @@ const NSTimeInterval MGLFlushInterval = 60; @property (nonatomic) NS_MUTABLE_ARRAY_OF(MGLMapboxEventAttributes *) *eventQueue; @property (nonatomic) dispatch_queue_t serialQueue; @property (nonatomic) dispatch_queue_t debugLogSerialQueue; -@property (nonatomic) CLLocationManager *locationManager; +@property (nonatomic) MGLLocationManager *locationManager; @property (nonatomic) NSTimer *timer; @end @@ -147,6 +145,8 @@ const NSTimeInterval MGLFlushInterval = 60; NSString *uniqueID = [[NSProcessInfo processInfo] globallyUniqueString]; _serialQueue = dispatch_queue_create([[NSString stringWithFormat:@"%@.%@.events.serial", _appBundleId, uniqueID] UTF8String], DISPATCH_QUEUE_SERIAL); + _locationManager = [[MGLLocationManager alloc] init]; + _locationManager.delegate = self; _paused = YES; [self resumeMetricsCollection]; NSBundle *resourceBundle = [NSBundle mgl_frameworkBundle]; @@ -177,6 +177,7 @@ const NSTimeInterval MGLFlushInterval = 60; } else { self.canEnableDebugLogging = YES; } + // Watch for changes to telemetry settings by the user __weak MGLMapboxEvents *weakSelf = self; @@ -186,8 +187,11 @@ const NSTimeInterval MGLFlushInterval = 60; usingBlock: ^(NSNotification *notification) { MGLMapboxEvents *strongSelf = weakSelf; - [strongSelf validate]; + [strongSelf pauseOrResumeMetricsCollectionIfRequired]; }]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(pauseOrResumeMetricsCollectionIfRequired) name:UIApplicationDidEnterBackgroundNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(pauseOrResumeMetricsCollectionIfRequired) name:UIApplicationWillEnterForegroundNotification object:nil]; } return self; } @@ -201,19 +205,9 @@ const NSTimeInterval MGLFlushInterval = 60; } static dispatch_once_t onceToken; static MGLMapboxEvents *_sharedManager; - void (^setupBlock)() = ^{ - dispatch_once(&onceToken, ^{ - _sharedManager = [[self alloc] init]; - }); - }; - if ( ! [[NSThread currentThread] isMainThread]) { - dispatch_sync(dispatch_get_main_queue(), ^{ - setupBlock(); - }); - } - else { - setupBlock(); - } + dispatch_once(&onceToken, ^{ + _sharedManager = [[self alloc] init]; + }); return _sharedManager; } @@ -222,50 +216,22 @@ const NSTimeInterval MGLFlushInterval = 60; [self pauseMetricsCollection]; } -+ (void)validate { - [[MGLMapboxEvents sharedManager] validate]; -} - -- (void)validate { - BOOL enabledInSettings = [[self class] isEnabled]; +- (void)pauseOrResumeMetricsCollectionIfRequired { + // Prevent blue status bar when host app has `when in use` permission only and it is not in foreground + if ([CLLocationManager authorizationStatus] == kCLAuthorizationStatusAuthorizedWhenInUse && + [UIApplication sharedApplication].applicationState == UIApplicationStateBackground) { + [self pauseMetricsCollection]; + return; + } + // Toggle pause based on current pause state and current settings state + // Practically, a pause only occurs because of a change to an NSUserDefaultsDidChangeNotification + BOOL enabledInSettings = [[self class] isEnabled]; if (self.paused && enabledInSettings) { [self resumeMetricsCollection]; } else if (!self.paused && !enabledInSettings) { [self pauseMetricsCollection]; } - - [self validateUpdatingLocation]; -} - -- (void)validateUpdatingLocation { - if (self.paused) { - [self stopUpdatingLocation]; - } else { - switch ([CLLocationManager authorizationStatus]) { - case kCLAuthorizationStatusNotDetermined: - case kCLAuthorizationStatusRestricted: - case kCLAuthorizationStatusDenied: - [self stopUpdatingLocation]; - break; - case kCLAuthorizationStatusAuthorized: - // Also handles kCLAuthorizationStatusAuthorizedAlways - [self startUpdatingLocation]; - break; - case kCLAuthorizationStatusAuthorizedWhenInUse: - if (UIApplication.sharedApplication.applicationState == UIApplicationStateBackground) { - // Prevent blue status bar when app is not in foreground - [self stopUpdatingLocation]; - } else { - [self startUpdatingLocation]; - } - break; - } - } -} - -+ (void)pauseMetricsCollection { - [[MGLMapboxEvents sharedManager] pauseMetricsCollection]; } - (void)pauseMetricsCollection { @@ -279,22 +245,7 @@ const NSTimeInterval MGLFlushInterval = 60; [self.eventQueue removeAllObjects]; self.data = nil; - [self validateUpdatingLocation]; -} - -- (void)stopUpdatingLocation { [self.locationManager stopUpdatingLocation]; - - // -[CLLocationManager stopMonitoringVisits] is only available in iOS 8+. - if ([self.locationManager respondsToSelector:@selector(stopMonitoringVisits)]) { - [self.locationManager stopMonitoringVisits]; - } - - self.locationManager = nil; -} - -+ (void)resumeMetricsCollection { - [[MGLMapboxEvents sharedManager] resumeMetricsCollection]; } - (void)resumeMetricsCollection { @@ -305,28 +256,7 @@ const NSTimeInterval MGLFlushInterval = 60; self.paused = NO; self.data = [[MGLMapboxEventsData alloc] init]; - [self validateUpdatingLocation]; -} - -- (void)startUpdatingLocation { - if (self.locationManager || self.paused) { - NSAssert(!(self.locationManager && self.paused), - @"MGLMapboxEvents should not have a CLLocationManager while paused."); - return; - } - - CLLocationManager *locationManager = [[CLLocationManager alloc] init]; - locationManager.delegate = self; - locationManager.desiredAccuracy = kCLLocationAccuracyKilometer; - locationManager.distanceFilter = 10; - [locationManager startUpdatingLocation]; - - // -[CLLocationManager startMonitoringVisits] is only available in iOS 8+. - if ([locationManager respondsToSelector:@selector(startMonitoringVisits)]) { - [locationManager startMonitoringVisits]; - } - - self.locationManager = locationManager; + [self.locationManager startUpdatingLocation]; } + (void)pushEvent:(NSString *)event withAttributes:(MGLMapboxEventAttributes *)attributeDictionary { @@ -403,7 +333,7 @@ const NSTimeInterval MGLFlushInterval = 60; } [strongSelf.apiClient postEvent:turnstileEventAttributes completionHandler:^(NSError * _Nullable error) { if (error) { - [MGLMapboxEvents pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription: @"Network error", + [strongSelf pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription: @"Network error", @"error": error}]; return; } @@ -435,7 +365,7 @@ const NSTimeInterval MGLFlushInterval = 60; self.timer = nil; } - [MGLMapboxEvents pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription:@"flush"}]; + [self pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription:@"flush"}]; } // Called implicitly from public use of +flush. @@ -450,11 +380,11 @@ const NSTimeInterval MGLFlushInterval = 60; __strong __typeof__(weakSelf) strongSelf = weakSelf; [self.apiClient postEvents:events completionHandler:^(NSError * _Nullable error) { if (error) { - [MGLMapboxEvents pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription: @"Network error", + [strongSelf pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription: @"Network error", @"error": error}]; return; } - [MGLMapboxEvents pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription: @"post", + [strongSelf pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription: @"post", @"debug.eventsCount": @(events.count)}]; }]; }); @@ -609,43 +539,39 @@ const NSTimeInterval MGLFlushInterval = 60; } } -#pragma mark CLLocationManagerDelegate +#pragma mark CLLocationManagerUtilityDelegate -- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations { - // Iterate through locations to pass all data +- (void)locationManager:(MGLLocationManager *)locationManager didUpdateLocations:(NSArray *)locations { for (CLLocation *loc in locations) { - [MGLMapboxEvents pushEvent:MGLEventTypeLocation withAttributes:@{ - MGLEventKeyLatitude: @(loc.coordinate.latitude), - MGLEventKeyLongitude: @(loc.coordinate.longitude), - MGLEventKeySpeed: @(loc.speed), - MGLEventKeyCourse: @(loc.course), - MGLEventKeyAltitude: @(round(loc.altitude)), - MGLEventKeyHorizontalAccuracy: @(round(loc.horizontalAccuracy)), - MGLEventKeyVerticalAccuracy: @(round(loc.verticalAccuracy)) - }]; + [MGLMapboxEvents pushEvent:MGLEventTypeLocation + withAttributes:@{MGLEventKeyLatitude: @(loc.coordinate.latitude), + MGLEventKeyLongitude: @(loc.coordinate.longitude), + MGLEventKeySpeed: @(loc.speed), + MGLEventKeyCourse: @(loc.course), + MGLEventKeyAltitude: @(round(loc.altitude)), + MGLEventKeyHorizontalAccuracy: @(round(loc.horizontalAccuracy)), + MGLEventKeyVerticalAccuracy: @(round(loc.verticalAccuracy))}]; } } -- (void)locationManager:(CLLocationManager *)manager didVisit:(CLVisit *)visit { - [MGLMapboxEvents pushEvent:MGLEventTypeVisit withAttributes:@{ - MGLEventKeyLatitude: @(visit.coordinate.latitude), - MGLEventKeyLongitude: @(visit.coordinate.longitude), - MGLEventKeyHorizontalAccuracy: @(round(visit.horizontalAccuracy)), - MGLEventKeyArrivalDate: [[NSDate distantPast] isEqualToDate:visit.arrivalDate] ? [NSNull null] : [_rfc3339DateFormatter stringFromDate:visit.arrivalDate], - MGLEventKeyDepartureDate: [[NSDate distantFuture] isEqualToDate:visit.departureDate] ? [NSNull null] : [_rfc3339DateFormatter stringFromDate:visit.departureDate] - }]; +- (void)locationManagerBackgroundLocationUpdatesDidAutomaticallyPause:(MGLLocationManager *)locationManager { + [self pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription:@"locationManager.locationManagerAutoPause"}]; } -- (void)locationManager:(CLLocationManager *)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status { - [self validateUpdatingLocation]; +- (void)locationManagerBackgroundLocationUpdatesDidTimeout:(MGLLocationManager *)locationManager { + [self pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription:@"locationManager.locationManagerTimeout"}]; +} + +- (void)locationManagerDidStartLocationUpdates:(MGLLocationManager *)locationManager { + [self pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription:@"locationManager.locationManagerStartUpdates"}]; +} + +- (void)locationManagerDidStopLocationUpdates:(MGLLocationManager *)locationManager { + [self pushDebugEvent:MGLEventTypeLocalDebug withAttributes:@{MGLEventKeyLocalDebugDescription: @"locationManager.locationManagerStopUpdates"}]; } #pragma mark MGLMapboxEvents Debug -+ (void)pushDebugEvent:(NSString *)event withAttributes:(MGLMapboxEventAttributes *)attributeDictionary { - [[MGLMapboxEvents sharedManager] pushDebugEvent:event withAttributes:attributeDictionary]; -} - - (void)pushDebugEvent:(NSString *)event withAttributes:(MGLMapboxEventAttributes *)attributeDictionary { if (![self debugLoggingEnabled]) { return;