chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
@@ -0,0 +1,14 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
extension CLLocation: Convertible {
public static func convert(from value: Any?, appContext: AppContext) throws -> Self {
if let value = value as? [String: Any] {
let args = try Conversions.pickValues(from: value, byKeys: ["latitude", "longitude"], as: Double.self)
// swiftlint:disable:next force_cast
return CLLocation(latitude: args[0], longitude: args[1]) as! Self
}
throw Conversions.ConvertingException<CLLocation>(value)
}
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2015-present 650 Industries. All rights reserved.
#import <CoreLocation/CLLocation.h>
#import <CoreLocation/CLLocationManager.h>
// Location accuracies
typedef NS_ENUM(NSUInteger, EXLocationAccuracy) {
EXLocationAccuracyLowest = 1,
EXLocationAccuracyLow = 2,
EXLocationAccuracyBalanced = 3,
EXLocationAccuracyHigh = 4,
EXLocationAccuracyHighest = 5,
EXLocationAccuracyBestForNavigation = 6,
};
@interface EXLocation : NSObject
+ (NSDictionary *)exportLocation:(CLLocation *)location;
+ (CLLocationAccuracy)CLLocationAccuracyFromOption:(EXLocationAccuracy)accuracy;
+ (CLActivityType)CLActivityTypeFromOption:(NSInteger)activityType;
@end
+58
View File
@@ -0,0 +1,58 @@
// Copyright 2016-present 650 Industries. All rights reserved.
#import <ExpoLocation/EXLocation.h>
NS_ASSUME_NONNULL_BEGIN
@implementation EXLocation
+ (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 (activityType == CLActivityTypeAirborne) {
return activityType;
}
return CLActivityTypeOther;
}
@end
NS_ASSUME_NONNULL_END
+27
View File
@@ -0,0 +1,27 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'ExpoLocation'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = package['author']
s.homepage = package['homepage']
s.platforms = {
:ios => '15.1'
}
s.source = { git: 'https://github.com/expo/expo.git' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
}
s.source_files = "**/*.{h,m,swift}"
end
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import CoreLocation
internal struct Geocoder {
static func geocode(address: String) async throws -> [[String: Any?]] {
do {
let geocoder = CLGeocoder()
let placemarks = try await geocoder.geocodeAddressString(address)
return placemarks.map { placemark in
let location = placemark.location
return [
"latitude": location?.coordinate.latitude,
"longitude": location?.coordinate.longitude,
"altitude": location?.altitude,
"accuracy": location?.horizontalAccuracy
]
}
} catch {
return try handleCLError(error: error as NSError, defaultValue: [])
}
}
static func reverseGeocode(location: CLLocation) async throws -> [[String: Any?]] {
do {
let geocoder = CLGeocoder()
let placemarks = try await geocoder.reverseGeocodeLocation(location)
return placemarks.map { placemark in
return [
"city": placemark.locality,
"district": placemark.subLocality,
"streetNumber": placemark.subThoroughfare,
"street": placemark.thoroughfare,
"region": placemark.administrativeArea,
"subregion": placemark.subAdministrativeArea,
"country": placemark.country,
"postalCode": placemark.postalCode,
"name": placemark.name,
"isoCountryCode": placemark.isoCountryCode,
"timezone": placemark.timeZone?.identifier
]
}
} catch {
return try handleCLError(error: error as NSError, defaultValue: [])
}
}
}
@@ -0,0 +1,8 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import ExpoModulesCore
internal struct LastKnownLocationRequirements: Record {
@Field var maxAge: Double = .greatestFiniteMagnitude
@Field var requiredAccuracy: Double = .greatestFiniteMagnitude
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
internal enum LocationAccuracy: Int, Enumerable {
case lowest = 1
case low = 2
case balanced = 3
case high = 4
case highest = 5
case bestForNavigation = 6
func toCLLocationAccuracy() -> CLLocationAccuracy {
switch self {
case .lowest:
return kCLLocationAccuracyThreeKilometers
case .low:
return kCLLocationAccuracyKilometer
case .balanced:
return kCLLocationAccuracyHundredMeters
case .high:
return kCLLocationAccuracyNearestTenMeters
case .highest:
return kCLLocationAccuracyBest
case .bestForNavigation:
return kCLLocationAccuracyBestForNavigation
}
}
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import ExpoModulesCore
extension Exceptions {
internal final class LocationUnavailable: Exception {
override var reason: String {
"Cannot obtain current location"
}
}
internal final class LocationRequestCanceled: Exception {
override var reason: String {
"Requesting the location has been canceled"
}
}
internal final class GeocodingNetwork: Exception {
override var reason: String {
"Geocoding rate limit exceeded - too many requests"
}
}
internal final class GeocodingFailed: Exception {
override var reason: String {
"Error while geocoding a location"
}
}
internal final class TaskManagerUnavailable: Exception {
override var reason: String {
"'expo-task-manager' module is required to use background services"
}
}
internal final class LocationUpdatesUnavailable: Exception {
override var reason: String {
"Background location has not been configured, make sure to add 'location' to 'UIBackgroundModes' in the Info.plist file"
}
}
internal final class HeadingUnavailableException: Exception {
override var reason: String {
"Heading updates not available"
}
}
internal final class GeofencingUnavailable: Exception {
override var reason: String {
"Geofencing is not available"
}
}
internal final class LocationServicesDisabled: Exception {
override var reason: String {
"Location services are disabled"
}
}
internal final class DeniedForegroundLocationPermission: Exception {
override var reason: String {
"Location permission is required to do this operation"
}
}
internal final class DeniedBackgroundLocationPermission: Exception {
override var reason: String {
"Background location permission is required to do this operation"
}
}
}
+223
View File
@@ -0,0 +1,223 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import CoreLocation
import ExpoModulesCore
private let EVENT_LOCATION_CHANGED = "Expo.locationChanged"
private let EVENT_HEADING_CHANGED = "Expo.headingChanged"
private let EVENT_LOCATION_ERROR = "Expo.locationError"
public final class LocationModule: Module {
private lazy var locationStreamers = [Int: BaseStreamer]()
private var taskManager: EXTaskManagerInterface {
get throws {
guard let taskManager: EXTaskManagerInterface = appContext?.legacyModule(implementing: EXTaskManagerInterface.self) else {
throw Exceptions.TaskManagerUnavailable()
}
return taskManager
}
}
public func definition() -> ModuleDefinition {
Name("ExpoLocation")
Events(EVENT_LOCATION_CHANGED, EVENT_HEADING_CHANGED, EVENT_LOCATION_ERROR)
OnCreate {
let permissionsManager = self.appContext?.permissions
EXPermissionsMethodsDelegate.register(
[
EXLocationPermissionRequester(),
EXForegroundPermissionRequester(),
EXBackgroundLocationPermissionRequester()
],
withPermissionsManager: permissionsManager
)
}
AsyncFunction("getProviderStatusAsync") {
return [
"locationServicesEnabled": CLLocationManager.locationServicesEnabled(),
"backgroundModeEnabled": true
]
}
AsyncFunction("getCurrentPositionAsync") { (options: LocationOptions) -> [String: Any] in
try ensureForegroundLocationPermissions(appContext)
let requester = await LocationRequester(options: options)
let location = try await requester.requestLocation()
return exportLocation(location)
}
AsyncFunction("watchPositionImplAsync") { (watchId: Int, options: LocationOptions) in
try ensureForegroundLocationPermissions(appContext)
let streamer = await LocationsStreamer(options: options)
locationStreamers[watchId] = streamer
// Start streaming in another task, so the returned promise is not waiting for the stream to end.
Task {
do {
for try await locations in try streamer.streamLocations() {
guard let location = locations.last else {
continue
}
sendEvent(EVENT_LOCATION_CHANGED, [
"watchId": watchId,
"location": exportLocation(location)
])
}
} catch let exception as Exception {
sendEvent(EVENT_LOCATION_ERROR, ["watchId": watchId, "reason": exception.reason])
} catch {
sendEvent(EVENT_LOCATION_ERROR, ["watchId": watchId, "reason": error.localizedDescription])
}
}
}
AsyncFunction("getLastKnownPositionAsync") { (requirements: LastKnownLocationRequirements) -> [String: Any]? in
try ensureForegroundLocationPermissions(appContext)
if let location = CLLocationManager().location, isLocation(location, valid: requirements) {
return exportLocation(location)
}
return nil
}
AsyncFunction("watchDeviceHeading") { (watchId: Int) in
try ensureForegroundLocationPermissions(appContext)
let options = LocationOptions(accuracy: .bestForNavigation, distanceInterval: 0)
let streamer = await DeviceHeadingStreamer(options: options)
locationStreamers[watchId] = streamer
// Start streaming in another task, so the returned promise is not waiting for the stream to end.
Task {
do {
for try await heading in try streamer.streamDeviceHeading() {
sendEvent(EVENT_HEADING_CHANGED, [
"watchId": watchId,
"heading": [
"trueHeading": heading.trueHeading,
"magHeading": heading.magneticHeading,
"accuracy": normalizeAccuracy(heading.headingAccuracy)
]
])
}
} catch let exception as Exception {
sendEvent(EVENT_LOCATION_ERROR, ["watchId": watchId, "reason": exception.reason])
} catch {
sendEvent(EVENT_LOCATION_ERROR, ["watchId": watchId, "reason": error.localizedDescription])
}
}
}
AsyncFunction("removeWatchAsync") { (watchId: Int) in
if let streamer = locationStreamers[watchId] {
streamer.stopStreaming()
}
locationStreamers[watchId] = nil
}
AsyncFunction("geocodeAsync") { (address: String) in
return try await Geocoder.geocode(address: address)
}
AsyncFunction("reverseGeocodeAsync") { (location: CLLocation) in
return try await Geocoder.reverseGeocode(location: location)
}
AsyncFunction("getPermissionsAsync") { (promise: Promise) in
try getPermissionUsingRequester(EXLocationPermissionRequester.self, appContext: appContext, promise: promise)
}
AsyncFunction("requestPermissionsAsync") { (promise: Promise) in
try askForPermissionUsingRequester(EXLocationPermissionRequester.self, appContext: appContext, promise: promise)
}
AsyncFunction("getForegroundPermissionsAsync") { (promise: Promise) in
try getPermissionUsingRequester(EXForegroundPermissionRequester.self, appContext: appContext, promise: promise)
}
AsyncFunction("requestForegroundPermissionsAsync") { (promise: Promise) in
try askForPermissionUsingRequester(EXForegroundPermissionRequester.self, appContext: appContext, promise: promise)
}
AsyncFunction("getBackgroundPermissionsAsync") { (promise: Promise) in
try getPermissionUsingRequester(EXBackgroundLocationPermissionRequester.self, appContext: appContext, promise: promise)
}
AsyncFunction("requestBackgroundPermissionsAsync") { (promise: Promise) in
try askForPermissionUsingRequester(EXBackgroundLocationPermissionRequester.self, appContext: appContext, promise: promise)
}
AsyncFunction("hasServicesEnabledAsync") {
return CLLocationManager.locationServicesEnabled()
}
// Background location
AsyncFunction("startLocationUpdatesAsync") { (taskName: String, options: [String: Any]) in
// There are two ways of starting this service.
// 1. As a background location service, this requires the background location permission.
// 2. As a user-initiated foreground service, this does NOT require the background location permission.
// Unfortunately, we cannot distinguish between those cases.
// So we only check foreground permission which needs to be granted in both cases.
try ensureLocationServicesEnabled()
try ensureForegroundLocationPermissions(appContext)
guard CLLocationManager.significantLocationChangeMonitoringAvailable() else {
throw Exceptions.LocationUpdatesUnavailable()
}
guard try taskManager.hasBackgroundModeEnabled("location") else {
throw Exceptions.LocationUpdatesUnavailable()
}
try taskManager.registerTask(withName: taskName, consumer: EXLocationTaskConsumer.self, options: options)
}
AsyncFunction("stopLocationUpdatesAsync") { (taskName: String) in
let taskManager = try taskManager
try EXUtilities.catchException {
taskManager.unregisterTask(withName: taskName, consumerClass: EXLocationTaskConsumer.self)
}
}
AsyncFunction("hasStartedLocationUpdatesAsync") { (taskName: String) -> Bool in
return try taskManager.task(withName: taskName, hasConsumerOf: EXLocationTaskConsumer.self)
}
// Geofencing
AsyncFunction("startGeofencingAsync") { (taskName: String, options: [String: Any]) in
try ensureBackgroundLocationPermissions(appContext)
guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else {
throw Exceptions.GeofencingUnavailable()
}
guard try taskManager.hasBackgroundModeEnabled("location") else {
throw Exceptions.LocationUpdatesUnavailable()
}
try taskManager.registerTask(withName: taskName, consumer: EXGeofencingTaskConsumer.self, options: options)
}
AsyncFunction("stopGeofencingAsync") { (taskName: String) in
let taskManager = try taskManager
try EXUtilities.catchException {
taskManager.unregisterTask(withName: taskName, consumerClass: EXGeofencingTaskConsumer.self)
}
}
AsyncFunction("hasStartedGeofencingAsync") { (taskName: String) -> Bool in
return try taskManager.task(withName: taskName, hasConsumerOf: EXGeofencingTaskConsumer.self)
}
}
}
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
internal struct LocationOptions: Record {
@Field var accuracy: LocationAccuracy = .balanced
@Field var distanceInterval: Double = 0.0
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
/**
Converts iOS heading accuracy to Android system.
3: high accuracy, 2: medium, 1: low, 0: none
*/
internal func normalizeAccuracy(_ accuracy: CLLocationDirection) -> Int {
if accuracy > 50 || accuracy < 0 {
return 0
}
if accuracy > 35 {
return 1
}
if accuracy > 20 {
return 2
}
return 3
}
internal func exportLocation(_ location: CLLocation) -> [String: Any] {
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
]
}
internal func handleCLError<ReturnType>(error: NSError, defaultValue: ReturnType) throws -> ReturnType {
switch CLError.Code(rawValue: error.code) {
case CLError.geocodeFoundNoResult, CLError.geocodeFoundPartialResult:
return defaultValue
case CLError.network:
throw Exceptions.GeocodingNetwork()
default:
throw Exceptions.GeocodingFailed().causedBy(error)
}
}
internal func ensureLocationServicesEnabled() throws {
guard CLLocationManager.locationServicesEnabled() else {
throw Exceptions.LocationServicesDisabled()
}
}
// MARK: - Permissions
internal func getPermissionUsingRequester<Requester: EXPermissionsRequester>(
_ requester: Requester.Type,
appContext: AppContext?,
promise: Promise
) throws {
guard let permissionsManager = appContext?.permissions else {
throw Exceptions.PermissionsModuleNotFound()
}
EXPermissionsMethodsDelegate.getPermissionWithPermissionsManager(
permissionsManager,
withRequester: Requester.self,
resolve: promise.resolver,
reject: promise.legacyRejecter
)
}
internal func askForPermissionUsingRequester<Requester: EXPermissionsRequester>(
_ requester: Requester.Type,
appContext: AppContext?,
promise: Promise
) throws {
guard let permissionsManager = appContext?.permissions else {
throw Exceptions.PermissionsModuleNotFound()
}
EXPermissionsMethodsDelegate.askForPermission(
withPermissionsManager: permissionsManager,
withRequester: Requester.self,
resolve: promise.resolver,
reject: promise.legacyRejecter
)
}
internal func checkPermissionWithRequester<Requester: EXPermissionsRequester>(
_ requester: Requester.Type,
appContext: AppContext?
) throws -> Bool {
guard let permissionsManager = appContext?.permissions else {
throw Exceptions.PermissionsModuleNotFound()
}
return permissionsManager.hasGrantedPermission(usingRequesterClass: Requester.self)
}
func ensureForegroundLocationPermissions(_ appContext: AppContext?) throws {
guard try checkPermissionWithRequester(EXForegroundPermissionRequester.self, appContext: appContext) else {
throw Exceptions.DeniedForegroundLocationPermission()
}
}
func ensureBackgroundLocationPermissions(_ appContext: AppContext?) throws {
guard try checkPermissionWithRequester(EXBackgroundLocationPermissionRequester.self, appContext: appContext) else {
throw Exceptions.DeniedBackgroundLocationPermission()
}
}
// MARK: - Other utils
func isLocation(_ location: CLLocation?, valid requirements: LastKnownLocationRequirements) -> Bool {
guard let location else {
return false
}
let timeDiff = -location.timestamp.timeIntervalSinceNow
return timeDiff * 1000 <= requirements.maxAge && location.horizontalAccuracy <= requirements.requiredAccuracy
}
@@ -0,0 +1,17 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import CoreLocation
internal class BaseLocationProvider: NSObject, CLLocationManagerDelegate {
internal let manager = CLLocationManager()
// CLLocationManager must be created on the main thread.
@MainActor
init(options: LocationOptions) {
super.init()
manager.allowsBackgroundLocationUpdates = false
manager.distanceFilter = options.distanceInterval
manager.desiredAccuracy = options.accuracy.toCLLocationAccuracy()
manager.delegate = self
}
}
@@ -0,0 +1,10 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import CoreLocation
import ExpoModulesCore
internal class BaseStreamer: BaseLocationProvider {
func stopStreaming() {
// Default empty implementation
}
}
@@ -0,0 +1,53 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import CoreLocation
import ExpoModulesCore
internal class DeviceHeadingStreamer: BaseStreamer {
typealias DeviceHeadingStream = AsyncThrowingStream<CLHeading, Error>
private var headingStream: DeviceHeadingStream?
private var continuation: DeviceHeadingStream.Continuation?
deinit {
if continuation != nil {
stopStreaming()
}
}
func streamDeviceHeading() throws -> DeviceHeadingStream {
if !CLLocationManager.headingAvailable() {
// Throw error
throw Exceptions.HeadingUnavailableException()
}
if let stream = headingStream {
return stream
}
let stream = DeviceHeadingStream { continuation in
self.continuation = continuation
manager.startUpdatingHeading()
}
headingStream = stream
return stream
}
override func stopStreaming() {
manager.stopUpdatingHeading()
continuation?.finish()
headingStream = nil
continuation = nil
}
// MARK: - CLLocationManagerDelegate
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
continuation?.yield(newHeading)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
continuation?.finish(throwing: Exceptions.HeadingUnavailableException().causedBy(error))
headingStream = nil
continuation = nil
}
}
@@ -0,0 +1,36 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import CoreLocation
import ExpoModulesCore
internal class LocationRequester: BaseLocationProvider {
private var continuation: CheckedContinuation<CLLocation, Error>?
deinit {
continuation?.resume(throwing: Exceptions.LocationRequestCanceled())
continuation = nil
}
func requestLocation() async throws -> CLLocation {
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
manager.requestLocation()
}
}
// MARK: - CLLocationManagerDelegate
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let lastLocation = locations.last {
continuation?.resume(returning: lastLocation)
} else {
continuation?.resume(throwing: Exceptions.LocationUnavailable())
}
continuation = nil
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
continuation?.resume(throwing: Exceptions.LocationUnavailable().causedBy(error))
continuation = nil
}
}
@@ -0,0 +1,55 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import CoreLocation
import ExpoModulesCore
internal class LocationsStreamer: BaseStreamer {
typealias LocationsStream = AsyncThrowingStream<[CLLocation], Error>
private var locationsStream: LocationsStream?
private var continuation: LocationsStream.Continuation?
deinit {
if continuation != nil {
stopStreaming()
}
}
func streamLocations() throws -> LocationsStream {
if !CLLocationManager.locationServicesEnabled() {
throw Exceptions.LocationServicesDisabled()
}
if let stream = locationsStream {
return stream
}
let stream = LocationsStream { continuation in
self.continuation = continuation
manager.startUpdatingLocation()
}
locationsStream = stream
return stream
}
override func stopStreaming() {
manager.stopUpdatingLocation()
continuation?.finish()
locationsStream = nil
continuation = nil
}
// MARK: - CLLocationManagerDelegate
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if !locations.isEmpty {
continuation?.yield(locations)
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
continuation?.finish(throwing: Exceptions.LocationUnavailable().causedBy(error))
locationsStream = nil
continuation = nil
}
}
@@ -0,0 +1,7 @@
// Copyright 2016-present 650 Industries. All rights reserved.
#import <ExpoLocation/EXBaseLocationRequester.h>
@interface EXBackgroundLocationPermissionRequester : EXBaseLocationRequester
@end
@@ -0,0 +1,157 @@
// Copyright 2016-present 650 Industries. All rights reserved.
#import <ExpoLocation/EXBackgroundLocationPermissionRequester.h>
#import <objc/message.h>
#import <CoreLocation/CLLocationManagerDelegate.h>
static SEL alwaysAuthorizationSelector;
@interface EXBackgroundLocationPermissionRequester ()
@property (nonatomic, assign) bool wasAsked;
@property (nonatomic, assign) bool isWaitingForTimeout;
@end
@implementation EXBackgroundLocationPermissionRequester
- (instancetype)init
{
if (self = [super init]) {
_wasAsked = false;
_isWaitingForTimeout = false;
}
return self;
}
+ (NSString *)permissionType
{
return @"locationBackground";
}
+ (void)load
{
alwaysAuthorizationSelector = NSSelectorFromString([@"request" stringByAppendingString:@"AlwaysAuthorization"]);
}
- (void)requestLocationPermissions
{
if ([EXBaseLocationRequester isConfiguredForAlwaysAuthorization] && [self.locationManager respondsToSelector:alwaysAuthorizationSelector]) {
_wasAsked = true;
CLAuthorizationStatus status = [self.locationManager authorizationStatus];
if (status == kCLAuthorizationStatusAuthorizedWhenInUse) {
// We already have a foreground permission granted:
// When asking for background location, we might or might not have asked for foreground permission
// before we get here. An issue here is if the user has a temporary permission ("Allow once") - which
// results in the status being "kCLAuthorizationStatusAuthorizedWhenInUse" - without us knowing.
// We need to handle this special case which is not possible to detect through the API.
// What we do is that we'll wait 1.5 seconds on an UIApplicationWillResignActiveNotification
// notification (which will be emitted almost directly if the permission dialog is displayed). If the permission
// dialog is not displayed we'll timeout and can resolve the waiting promise with an updated denied status.
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleAppBecomingInactive)
name:UIApplicationWillResignActiveNotification
object:nil];
// Setup timeout - if no permission dialog was displayed we can just stop listening and deny
// the request
[self setupAppInactivateTimeout];
}
// Request permissions
((void (*)(id, SEL))objc_msgSend)(self.locationManager, alwaysAuthorizationSelector);
} else {
self.reject(@"ERR_LOCATION_INFO_PLIST", @"One of the `NSLocation*UsageDescription` keys must be present in Info.plist to be able to use geolocation.", nil);
self.resolve = nil;
self.reject = nil;
}
}
- (void)handleAppBecomingActive
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
if (self.resolve) {
self.resolve([self getPermissions]);
self.resolve = nil;
self.reject = nil;
}
}
- (void)handleAppBecomingInactive
{
// Let's wait until the app becomes inactive - this happens when OS displays the
// permission dialog - then we can cancel the timeout handler.
_isWaitingForTimeout = false;
[[NSNotificationCenter defaultCenter] removeObserver:self];
// When the app is inactive it means that a permission dialog is showing and we should ask to be
// notified when the dialog is closed:
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleAppBecomingActive)
name:UIApplicationDidBecomeActiveNotification
object:nil];
}
- (void)setupAppInactivateTimeout
{
_isWaitingForTimeout = true;
// Obtain a reference to the current queue
dispatch_queue_t currentQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0);
// Calculate the time for the delay
dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC));
EX_WEAKIFY(self);
// Schedule the block to be executed after the delay
dispatch_after(delayTime, currentQueue, ^{
EX_ENSURE_STRONGIFY(self)
// Check if we are still waiting - ie. we haven't seen a permission dialog
if (self.isWaitingForTimeout && self.resolve) {
self.isWaitingForTimeout = false;
self.resolve([self getPermissions]);
self.resolve = nil;
self.reject = nil;
}
});
}
- (NSDictionary *)parsePermissions:(CLAuthorizationStatus)systemStatus
{
EXPermissionStatus status;
switch (systemStatus) {
case kCLAuthorizationStatusAuthorizedAlways: {
status = EXPermissionStatusGranted;
break;
}
case kCLAuthorizationStatusDenied:
case kCLAuthorizationStatusRestricted: {
status = EXPermissionStatusDenied;
break;
}
case kCLAuthorizationStatusAuthorizedWhenInUse: {
if (_wasAsked) {
status = EXPermissionStatusDenied;
} else {
status = EXPermissionStatusUndetermined;
}
break;
}
case kCLAuthorizationStatusNotDetermined:
default: {
status = EXPermissionStatusUndetermined;
break;
}
}
return @{ @"status": @(status), @"scope": @(systemStatus == kCLAuthorizationStatusAuthorizedWhenInUse ? "whenInUse" : systemStatus == kCLAuthorizationStatusAuthorizedAlways ? "always" : "none") };
}
@end
@@ -0,0 +1,20 @@
// Copyright 2016-present 650 Industries. All rights reserved.
#import <CoreLocation/CLLocationManager.h>
#import <ExpoModulesCore/EXPermissionsInterface.h>
#import <CoreLocation/CLLocationManagerDelegate.h>
@interface EXBaseLocationRequester : NSObject<EXPermissionsRequester, CLLocationManagerDelegate>
@property (nonatomic, strong) CLLocationManager *locationManager;
@property (nonatomic, strong) EXPromiseResolveBlock resolve;
@property (nonatomic, strong) EXPromiseRejectBlock reject;
+ (BOOL)isConfiguredForWhenInUseAuthorization;
+ (BOOL)isConfiguredForAlwaysAuthorization;
- (void)requestLocationPermissions;
- (NSDictionary *)parsePermissions:(CLAuthorizationStatus)systemStatus;
@end
@@ -0,0 +1,162 @@
// Copyright 2016-present 650 Industries. All rights reserved.
#import <ExpoLocation/EXBaseLocationRequester.h>
#import <ExpoModulesCore/EXUtilities.h>
#import <objc/message.h>
@interface EXBaseLocationRequester () <CLLocationManagerDelegate>
@property (nonatomic, assign) bool locationManagerWasCalled;
@property (nonatomic, assign) CLAuthorizationStatus beginStatus;
@end
@implementation EXBaseLocationRequester
# pragma mark - Abstract methods
- (void)requestLocationPermissions
{
@throw([NSException exceptionWithName:@"NotImplemented" reason:@"requestLocationPermissions should be implemented" userInfo:nil]);
}
+ (NSString *)permissionType {
@throw([NSException exceptionWithName:@"NotImplemented" reason:@"permissionType should be implemented" userInfo:nil]);
}
- (NSDictionary *)parsePermissions:(CLAuthorizationStatus)systemStatus
{
@throw([NSException exceptionWithName:@"NotImplemented" reason:@"parsePermissions should be implemented" userInfo:nil]);
}
# pragma mark - UMPermissionsRequester
- (NSDictionary *)getPermissions {
CLAuthorizationStatus systemStatus;
if (![EXBaseLocationRequester isConfiguredForAlwaysAuthorization] && ![EXBaseLocationRequester isConfiguredForWhenInUseAuthorization]) {
EXFatal(EXErrorWithMessage(@"This app is missing usage descriptions, so location services will fail. Add one of the `NSLocation*UsageDescription` keys to your bundle's Info.plist. See https://bit.ly/3iLqy6S (https://docs.expo.dev/distribution/app-stores/#system-permissions-dialogs-on-ios) for more information."));
systemStatus = kCLAuthorizationStatusDenied;
} else {
systemStatus = [CLLocationManager authorizationStatus];
}
return [self parsePermissions:systemStatus];
}
- (void)requestPermissionsWithResolver:(EXPromiseResolveBlock)resolve rejecter:(EXPromiseRejectBlock)reject {
NSDictionary *existingPermissions = [self getPermissions];
if (existingPermissions && [existingPermissions[@"status"] intValue] != EXPermissionStatusUndetermined) {
// since permissions are already determined, the iOS request methods will be no-ops.
// just resolve with whatever existing permissions.
resolve(existingPermissions);
} else {
_resolve = resolve;
_reject = reject;
EX_WEAKIFY(self)
[EXUtilities performSynchronouslyOnMainThread:^{
EX_ENSURE_STRONGIFY(self)
self.locationManagerWasCalled = false;
self.locationManager = [[CLLocationManager alloc] init];
self.locationManager.delegate = self;
}];
// 1. Why do we call CLLocationManager methods by those dynamically created selectors?
//
// Most probably application code submitted to Apple Store is statically analyzed
// paying special attention to camelcase(request_always_location) being called on CLLocationManager.
// This lets Apple warn developers when it notices that location authorization may be requested
// while there is no NSLocationUsageDescription in Info.plist. Since we want to neither
// make Expo developers receive this kind of messages nor add our own default usage description,
// we try to fool the static analyzer and construct the selector in runtime.
// This way behavior of this requester is governed by provided NSLocationUsageDescriptions.
// 2. Location permission request types
//
// Foreground
// - "Allow once"
// - "Allow while using App"
// - "Don't allow"
//
// Background
// - "Keep only while using"
// - "Change to always allow"
//
// Requesting background permissions directly without first asking for foreground permissions is the
// same as asking for foreground permissions and then asking for background permissions.
//
// "Allow once" is a temporary permission (limited to the current app session). It is not possible to get
// info from the API about wether or not the current permission is temporary. You cannot request background
// permissions with a temporary token - a background request will then return denied.
//
// Requesting background permissions directly and "Allow while using the App" gives you a provisional
// background permission that can later be elevated to a full "Always allow" permission.
// You will be asked at a later point if you want to convert to "Always allow". The system waits until
// you have started using the newly aquired permission before showing the permission dialog.
//
// Test the following scenarios in BareExpo -> APIs -> Location
// ------------------------------------------------------------
// (before tests, make sure to clear any location permissions and restart the app)
//
// rfp = requestForegroundPermissionsAsync, fp: Actual foreground permission given
// rbp = requestBackgroundPermissionsAsync, bg: Actual background permission given
//
// - rfp -> "Allow once", then rbp -> no dialog = (fp: granted (temporary), bg: denied after 1.5 seconds)
// - rfp -> "Allow while using App", then rbp -> "Keep only while using" = (fp: granted, bg: denied)
// - rfp -> "Allow while using App", then rbp -> "Change to always allow" = (fp: granted, bg: granted)
// - rfp -> "Don't allow", then rbp -> no dialog = (fp: denied, bg: denied)
// - rbp -> "Allow once", no more dalogs = (fp: granted (temporary), bg: denied)
// - rbp -> "Allow while using App", no more dialogs = (fp: granted, bg: granted (provisional))
// - rbp -> "Don't allow" = (fp: denied, bg: denied)
// Save start statue and call requestLocationPermissions
_beginStatus = [self.locationManager authorizationStatus];
[self requestLocationPermissions];
}
}
#pragma mark - CLLocationManagerDelegate
- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error
{
if (_reject) {
_reject(@"E_LOCATION_ERROR_UNKNOWN", error.localizedDescription, error);
_resolve = nil;
_reject = nil;
}
}
- (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager
{
CLAuthorizationStatus nextState = [manager authorizationStatus];
if (_beginStatus == nextState && !_locationManagerWasCalled) {
// CLLocationManager calls this delegate method once on start with kCLAuthorizationNotDetermined even before the user responds
// to the "Don't Allow" / "Allow" dialog box. This isn't the event we care about so we skip it. See:
// http://stackoverflow.com/questions/30106341/swift-locationmanager-didchangeauthorizationstatus-always-called/30107511#30107511
_locationManagerWasCalled = true;
return;
}
if (_resolve) {
_resolve([self getPermissions]);
_resolve = nil;
_reject = nil;
}
}
#pragma mark - Helpers
+ (BOOL)isConfiguredForWhenInUseAuthorization
{
return [[NSBundle mainBundle] objectForInfoDictionaryKey:@"NSLocationWhenInUseUsageDescription"] != nil;
}
+ (BOOL)isConfiguredForAlwaysAuthorization
{
return [self isConfiguredForWhenInUseAuthorization] && [[NSBundle mainBundle] objectForInfoDictionaryKey:@"NSLocationAlwaysAndWhenInUseUsageDescription"];
}
@end
@@ -0,0 +1,8 @@
// Copyright 2016-present 650 Industries. All rights reserved.
#import <ExpoLocation/EXBaseLocationRequester.h>
@interface EXForegroundPermissionRequester : EXBaseLocationRequester
@end
@@ -0,0 +1,60 @@
// Copyright 2016-present 650 Industries. All rights reserved.
#import <ExpoLocation/EXForegroundPermissionRequester.h>
#import <ExpoModulesCore/EXUtilities.h>
#import <objc/message.h>
#import <CoreLocation/CLLocationManagerDelegate.h>
static SEL whenInUseAuthorizationSelector;
@implementation EXForegroundPermissionRequester
+ (NSString *)permissionType
{
return @"locationForeground";
}
+ (void)load
{
whenInUseAuthorizationSelector = NSSelectorFromString([@"request" stringByAppendingString:@"WhenInUseAuthorization"]);
}
- (void)requestLocationPermissions
{
if ([EXBaseLocationRequester isConfiguredForWhenInUseAuthorization] && [self.locationManager respondsToSelector:whenInUseAuthorizationSelector]) {
((void (*)(id, SEL))objc_msgSend)(self.locationManager, whenInUseAuthorizationSelector);
} else {
self.reject(@"ERR_LOCATION_INFO_PLIST", @"The `NSLocationWhenInUseUsageDescription` key must be present in Info.plist to be able to use geolocation.", nil);
self.resolve = nil;
self.reject = nil;
}
}
- (NSDictionary *)parsePermissions:(CLAuthorizationStatus)systemStatus
{
EXPermissionStatus status;
switch (systemStatus) {
case kCLAuthorizationStatusAuthorizedWhenInUse:
case kCLAuthorizationStatusAuthorizedAlways: {
status = EXPermissionStatusGranted;
break;
}
case kCLAuthorizationStatusDenied:
case kCLAuthorizationStatusRestricted: {
status = EXPermissionStatusDenied;
break;
}
case kCLAuthorizationStatusNotDetermined:
default: {
status = EXPermissionStatusUndetermined;
break;
}
}
return @{ @"status": @(status), @"scope": @(systemStatus == kCLAuthorizationStatusAuthorizedWhenInUse ? "whenInUse" : systemStatus == kCLAuthorizationStatusAuthorizedAlways ? "always" : "none") };
}
@end
@@ -0,0 +1,7 @@
// Copyright 2016-present 650 Industries. All rights reserved.
#import <ExpoLocation/EXBaseLocationRequester.h>
@interface EXLocationPermissionRequester : EXBaseLocationRequester
@end
@@ -0,0 +1,72 @@
// Copyright 2016-present 650 Industries. All rights reserved.
#import <ExpoLocation/EXLocationPermissionRequester.h>
#import <objc/message.h>
#import <CoreLocation/CLLocationManagerDelegate.h>
static SEL alwaysAuthorizationSelector;
static SEL whenInUseAuthorizationSelector;
@implementation EXLocationPermissionRequester
+ (NSString *)permissionType
{
return @"location";
}
+ (void)load
{
alwaysAuthorizationSelector = NSSelectorFromString([@"request" stringByAppendingString:@"AlwaysAuthorization"]);
whenInUseAuthorizationSelector = NSSelectorFromString([@"request" stringByAppendingString:@"WhenInUseAuthorization"]);
}
- (void)requestLocationPermissions
{
if ([EXBaseLocationRequester isConfiguredForAlwaysAuthorization] && [self.locationManager respondsToSelector:alwaysAuthorizationSelector]) {
((void (*)(id, SEL))objc_msgSend)(self.locationManager, alwaysAuthorizationSelector);
} else if ([EXBaseLocationRequester isConfiguredForWhenInUseAuthorization] && [self.locationManager respondsToSelector:whenInUseAuthorizationSelector]) {
((void (*)(id, SEL))objc_msgSend)(self.locationManager, whenInUseAuthorizationSelector);
} else {
self.reject(@"E_LOCATION_INFO_PLIST", @"One of the `NSLocation*UsageDescription` keys must be present in Info.plist to be able to use geolocation.", nil);
self.resolve = nil;
self.reject = nil;
}
}
- (NSDictionary *)parsePermissions:(CLAuthorizationStatus)systemStatus
{
EXPermissionStatus status;
NSString *scope = @"none";
switch (systemStatus) {
case kCLAuthorizationStatusAuthorizedWhenInUse: {
status = EXPermissionStatusGranted;
scope = @"whenInUse";
break;
}
case kCLAuthorizationStatusAuthorizedAlways: {
status = EXPermissionStatusGranted;
scope = @"always";
break;
}
case kCLAuthorizationStatusDenied:
case kCLAuthorizationStatusRestricted: {
status = EXPermissionStatusDenied;
break;
}
case kCLAuthorizationStatusNotDetermined:
default: {
status = EXPermissionStatusUndetermined;
break;
}
}
return @{
@"status": @(status),
@"scope": scope
};
}
@end
@@ -0,0 +1,27 @@
// Copyright 2018-present 650 Industries. All rights reserved.
#import <CoreLocation/CLLocationManagerDelegate.h>
#import <ExpoModulesCore/EXTaskConsumerInterface.h>
NS_ASSUME_NONNULL_BEGIN
// Geofencing event types
typedef NS_ENUM(NSUInteger, EXGeofencingEventType) {
EXGeofencingEventTypeEnter = 1,
EXGeofencingEventTypeExit = 2,
};
// Geofencing region states
typedef NS_ENUM(NSUInteger, EXGeofencingRegionState) {
EXGeofencingRegionStateUnknown = 0,
EXGeofencingRegionStateInside = 1,
EXGeofencingRegionStateOutside = 2,
};
@interface EXGeofencingTaskConsumer : NSObject <EXTaskConsumerInterface, CLLocationManagerDelegate>
@property (nonatomic, strong) id<EXTaskInterface> task;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,215 @@
// Copyright 2018-present 650 Industries. All rights reserved.
#import <CoreLocation/CLCircularRegion.h>
#import <CoreLocation/CLLocationManager.h>
#import <CoreLocation/CLErrorDomain.h>
#import <ExpoModulesCore/EXUtilities.h>
#import <ExpoModulesCore/EXTaskInterface.h>
#import <ExpoLocation/EXLocation.h>
#import <ExpoLocation/EXGeofencingTaskConsumer.h>
@interface EXGeofencingTaskConsumer ()
@property (nonatomic, strong) CLLocationManager *locationManager;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *regionStates;
@property (nonatomic, assign) BOOL backgroundOnly;
@end
@implementation EXGeofencingTaskConsumer
- (void)dealloc
{
[self reset];
}
# pragma mark - EXTaskConsumerInterface
- (NSString *)taskType
{
return @"geofencing";
}
- (void)setOptions:(nonnull NSDictionary *)options
{
[self stopMonitoringAllRegions];
[self startMonitoringRegionsForTask:self->_task];
}
- (void)didRegisterTask:(id<EXTaskInterface>)task
{
[self startMonitoringRegionsForTask:task];
}
- (void)didUnregister
{
[self reset];
}
# pragma mark - helpers
- (void)reset
{
[self stopMonitoringAllRegions];
[EXUtilities performSynchronouslyOnMainThread:^{
self->_locationManager = nil;
self->_task = nil;
}];
}
- (void)startMonitoringRegionsForTask:(id<EXTaskInterface>)task
{
[EXUtilities performSynchronouslyOnMainThread:^{
CLLocationManager *locationManager = [CLLocationManager new];
NSMutableDictionary *regionStates = [NSMutableDictionary new];
NSDictionary *options = [task options];
NSArray *regions = options[@"regions"];
self->_task = task;
self->_locationManager = locationManager;
self->_regionStates = regionStates;
locationManager.delegate = self;
locationManager.allowsBackgroundLocationUpdates = YES;
locationManager.pausesLocationUpdatesAutomatically = NO;
for (NSDictionary *regionDict in regions) {
NSString *identifier = regionDict[@"identifier"] ?: [[NSUUID UUID] UUIDString];
CLLocationDistance radius = [regionDict[@"radius"] doubleValue];
CLLocationCoordinate2D center = [self.class coordinateFromDictionary:regionDict];
BOOL notifyOnEntry = [self.class boolValueFrom:regionDict[@"notifyOnEntry"] defaultValue:YES];
BOOL notifyOnExit = [self.class boolValueFrom:regionDict[@"notifyOnExit"] defaultValue:YES];
CLCircularRegion *region = [[CLCircularRegion alloc] initWithCenter:center radius:radius identifier:identifier];
region.notifyOnEntry = notifyOnEntry;
region.notifyOnExit = notifyOnExit;
[regionStates setObject:@(CLRegionStateUnknown) forKey:identifier];
[locationManager startMonitoringForRegion:region];
[locationManager requestStateForRegion:region];
}
}];
}
- (void)stopMonitoringAllRegions
{
[EXUtilities performSynchronouslyOnMainThread:^{
for (CLRegion *region in self->_locationManager.monitoredRegions) {
[self->_locationManager stopMonitoringForRegion:region];
}
}];
}
- (void)executeTaskWithRegion:(nonnull CLRegion *)region eventType:(EXGeofencingEventType)eventType
{
if ([region isKindOfClass:[CLCircularRegion class]]) {
CLCircularRegion *circularRegion = (CLCircularRegion *)region;
CLRegionState regionState = [self regionStateForIdentifier:circularRegion.identifier];
NSDictionary *data = @{
@"eventType": @(eventType),
@"region": [[self class] exportRegion:circularRegion withState:regionState],
};
[_task executeWithData:data withError:nil];
}
}
# pragma mark - CLLocationManagerDelegate
// There is a bug in iOS that causes didEnterRegion and didExitRegion to be called multiple times.
// https://stackoverflow.com/questions/36807060/region-monitoring-method-getting-called-multiple-times-in-geo-fencing
// To prevent this behavior, we execute tasks only when the state has changed.
- (void)locationManager:(CLLocationManager *)manager didEnterRegion:(CLRegion *)region
{
if ([self regionStateForIdentifier:region.identifier] != CLRegionStateInside) {
[self setRegionState:CLRegionStateInside forIdentifier:region.identifier];
[self executeTaskWithRegion:region eventType:EXGeofencingEventTypeEnter];
}
}
- (void)locationManager:(CLLocationManager *)manager didExitRegion:(CLRegion *)region
{
if ([self regionStateForIdentifier:region.identifier] != CLRegionStateOutside) {
[self setRegionState:CLRegionStateOutside forIdentifier:region.identifier];
[self executeTaskWithRegion:region eventType:EXGeofencingEventTypeExit];
}
}
- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error
{
[_task executeWithData:nil withError:error];
}
- (void)locationManager:(CLLocationManager *)manager monitoringDidFailForRegion:(CLRegion *)region withError:(NSError *)error
{
if (error && error.domain == kCLErrorDomain) {
// This error might happen when the device is not able to find out the location. Try to restart monitoring this region.
[_locationManager stopMonitoringForRegion:region];
[_locationManager startMonitoringForRegion:region];
[_locationManager requestStateForRegion:region];
}
}
- (void)locationManager:(CLLocationManager *)manager didDetermineState:(CLRegionState)state forRegion:(CLRegion *)region
{
if ([self regionStateForIdentifier:region.identifier] != state) {
EXGeofencingEventType eventType = state == CLRegionStateInside ? EXGeofencingEventTypeEnter : EXGeofencingEventTypeExit;
[self setRegionState:state forIdentifier:region.identifier];
[self executeTaskWithRegion:region eventType:eventType];
}
}
# pragma mark - helpers
- (CLRegionState)regionStateForIdentifier:(NSString *)identifier
{
return [_regionStates[identifier] integerValue];
}
- (void)setRegionState:(CLRegionState)regionState forIdentifier:(NSString *)identifier
{
[_regionStates setObject:@(regionState) forKey:identifier];
}
# pragma mark - static helpers
+ (nonnull NSDictionary *)exportRegion:(nonnull CLCircularRegion *)region withState:(CLRegionState)regionState
{
return @{
@"identifier": region.identifier,
@"state": @([self exportRegionState:regionState]),
@"radius": @(region.radius),
@"latitude": @(region.center.latitude),
@"longitude": @(region.center.longitude),
};
}
+ (EXGeofencingRegionState)exportRegionState:(CLRegionState)regionState
{
switch (regionState) {
case CLRegionStateUnknown:
return EXGeofencingRegionStateUnknown;
case CLRegionStateInside:
return EXGeofencingRegionStateInside;
case CLRegionStateOutside:
return EXGeofencingRegionStateOutside;
}
}
+ (CLLocationCoordinate2D)coordinateFromDictionary:(nonnull NSDictionary *)dict
{
CLLocationDegrees latitude = [dict[@"latitude"] doubleValue];
CLLocationDegrees longitude = [dict[@"longitude"] doubleValue];
return CLLocationCoordinate2DMake(latitude, longitude);
}
+ (BOOL)boolValueFrom:(id)pointer defaultValue:(BOOL)defaultValue
{
return pointer == nil ? defaultValue : [pointer boolValue];
}
@end
@@ -0,0 +1,14 @@
// Copyright 2018-present 650 Industries. All rights reserved.
#import <CoreLocation/CLLocationManagerDelegate.h>
#import <ExpoModulesCore/EXTaskConsumerInterface.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXLocationTaskConsumer : NSObject <EXTaskConsumerInterface, CLLocationManagerDelegate>
@property (nonatomic, strong) id<EXTaskInterface> task;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,189 @@
// Copyright 2018-present 650 Industries. All rights reserved.
#import <CoreLocation/CLLocationManager.h>
#import <CoreLocation/CLErrorDomain.h>
#import <ExpoModulesCore/EXUtilities.h>
#import <ExpoModulesCore/EXTaskInterface.h>
#import <ExpoLocation/EXLocation.h>
#import <ExpoLocation/EXLocationTaskConsumer.h>
@interface EXLocationTaskConsumer ()
@property (nonatomic, strong) CLLocationManager *locationManager;
@property (nonatomic, strong) NSMutableArray<CLLocation *> *deferredLocations;
@property (nonatomic, strong) CLLocation *lastReportedLocation;
@property (nonatomic, assign) CLLocationDistance deferredDistance;
@end
@implementation EXLocationTaskConsumer
- (instancetype)init
{
if (self = [super init]) {
_deferredLocations = [NSMutableArray new];
_deferredDistance = 0.0;
}
return self;
}
- (void)dealloc
{
[self reset];
}
# pragma mark - EXTaskConsumerInterface
- (NSString *)taskType
{
return @"location";
}
- (void)didRegisterTask:(id<EXTaskInterface>)task
{
[EXUtilities performSynchronouslyOnMainThread:^{
CLLocationManager *locationManager = [CLLocationManager new];
self->_task = task;
self->_locationManager = locationManager;
locationManager.delegate = self;
locationManager.allowsBackgroundLocationUpdates = YES;
// Set options-specific things in location manager.
[self setOptions:task.options];
}];
}
- (void)didUnregister
{
[self reset];
}
- (void)setOptions:(NSDictionary *)options
{
[EXUtilities performSynchronouslyOnMainThread:^{
CLLocationManager *locationManager = self->_locationManager;
EXLocationAccuracy accuracy = [options[@"accuracy"] unsignedIntegerValue] ?: EXLocationAccuracyBalanced;
locationManager.desiredAccuracy = [EXLocation CLLocationAccuracyFromOption:accuracy];
locationManager.distanceFilter = [self numberToDouble:options[@"distanceInterval"] defaultValue:kCLDistanceFilterNone];
locationManager.activityType = [EXLocation CLActivityTypeFromOption:[self numberToInteger:options[@"activityType"] defaultValue:CLActivityTypeOther]];
locationManager.pausesLocationUpdatesAutomatically = [self numberToBool:options[@"pausesUpdatesAutomatically"] defaultValue:true];
locationManager.showsBackgroundLocationIndicator = [self numberToBool:options[@"showsBackgroundLocationIndicator"] defaultValue:false];
[locationManager startUpdatingLocation];
[locationManager startMonitoringSignificantLocationChanges];
}];
}
# pragma mark - CLLocationManagerDelegate
- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray<CLLocation *> *)locations
{
if (_task != nil && locations.count > 0) {
[self deferLocations:locations];
[self maybeReportDeferredLocations];
}
}
- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error
{
[_task executeWithData:nil withError:error];
}
# pragma mark - internal
- (void)reset
{
[EXUtilities performSynchronouslyOnMainThread:^{
[self->_locationManager stopUpdatingLocation];
[self->_locationManager stopMonitoringSignificantLocationChanges];
[self->_deferredLocations removeAllObjects];
self->_lastReportedLocation = nil;
self->_deferredDistance = 0.0;
self->_locationManager = nil;
self->_task = nil;
}];
}
- (void)executeTaskWithDeferredLocations
{
// Execute task with deferred locations.
NSDictionary *data = @{ @"locations": [EXLocationTaskConsumer _exportLocations:_deferredLocations] };
[_task executeWithData:data withError:nil];
// Reset deferring state.
_lastReportedLocation = _deferredLocations.lastObject;
_deferredDistance = 0.0;
[_deferredLocations removeAllObjects];
}
- (void)maybeReportDeferredLocations
{
if ([self shouldReportDeferredLocations]) {
[self executeTaskWithDeferredLocations];
}
}
- (void)deferLocations:(NSArray<CLLocation *> *)locations
{
CLLocation *lastLocation = _deferredLocations.lastObject ?: _lastReportedLocation;
for (CLLocation *location in locations) {
if (lastLocation) {
_deferredDistance += [location distanceFromLocation:lastLocation];
}
lastLocation = location;
}
[_deferredLocations addObjectsFromArray:locations];
}
- (BOOL)shouldReportDeferredLocations
{
if (_deferredLocations.count <= 0) {
return NO;
}
UIApplicationState appState = [[UIApplication sharedApplication] applicationState];
if (appState == UIApplicationStateActive) {
// Don't defer location updates when app is in foreground state.
return YES;
}
CLLocation *oldestLocation = _lastReportedLocation ?: _deferredLocations.firstObject;
CLLocation *newestLocation = _deferredLocations.lastObject;
NSDictionary *options = _task.options;
CLLocationDistance distance = [self numberToDouble:options[@"deferredUpdatesDistance"] defaultValue:0];
NSTimeInterval interval = [self numberToDouble:options[@"deferredUpdatesInterval"] defaultValue:0];
return [newestLocation.timestamp timeIntervalSinceDate:oldestLocation.timestamp] >= interval / 1000.0 && _deferredDistance >= distance;
}
- (double)numberToDouble:(NSNumber *)number defaultValue:(double)defaultValue
{
return [number isEqual:[NSNull null]] || number == nil ? defaultValue : [number doubleValue];
}
- (NSInteger)numberToInteger:(NSNumber *)number defaultValue:(NSInteger)defaultValue
{
return [number isEqual:[NSNull null]] || number == nil ? defaultValue : [number integerValue];
}
- (BOOL)numberToBool:(NSNumber *)number defaultValue:(BOOL)defaultValue
{
return [number isEqual:[NSNull null]] || number == nil ? defaultValue : [number boolValue];
}
+ (NSArray<NSDictionary *> *)_exportLocations:(NSArray<CLLocation *> *)locations
{
NSMutableArray<NSDictionary *> *result = [NSMutableArray new];
for (CLLocation *location in locations) {
[result addObject:[EXLocation exportLocation:location]];
}
return result;
}
@end