UNPKG

16.1 kBPlain TextView Raw
1import { EventEmitter, Platform, CodedError } from '@unimodules/core';
2import invariant from 'invariant';
3
4import ExpoLocation from './ExpoLocation';
5
6const LocationEventEmitter = new EventEmitter(ExpoLocation);
7
8export interface ProviderStatus {
9 locationServicesEnabled: boolean;
10 backgroundModeEnabled: boolean;
11 gpsAvailable?: boolean;
12 networkAvailable?: boolean;
13 passiveAvailable?: boolean;
14}
15
16export interface LocationOptions {
17 accuracy?: LocationAccuracy;
18 enableHighAccuracy?: boolean;
19 timeInterval?: number;
20 distanceInterval?: number;
21 timeout?: number;
22 mayShowUserSettingsDialog?: boolean;
23}
24
25export interface LocationData {
26 coords: {
27 latitude: number;
28 longitude: number;
29 altitude: number;
30 accuracy: number;
31 heading: number;
32 speed: number;
33 };
34 timestamp: number;
35}
36
37export interface HeadingData {
38 trueHeading: number;
39 magHeading: number;
40 accuracy: number;
41}
42
43export interface GeocodedLocation {
44 latitude: number;
45 longitude: number;
46 altitude?: number;
47 accuracy?: number;
48}
49
50export interface Address {
51 city: string;
52 street: string;
53 region: string;
54 country: string;
55 postalCode: string;
56 name: string;
57}
58
59interface LocationTaskOptions {
60 accuracy?: LocationAccuracy;
61 timeInterval?: number; // Android only
62 distanceInterval?: number;
63 showsBackgroundLocationIndicator?: boolean; // iOS only
64 deferredUpdatesDistance?: number;
65 deferredUpdatesTimeout?: number;
66 deferredUpdatesInterval?: number;
67
68 // iOS only
69 activityType?: LocationActivityType;
70 pausesUpdatesAutomatically?: boolean;
71
72 foregroundService?: {
73 notificationTitle: string;
74 notificationBody: string;
75 notificationColor?: string;
76 };
77}
78
79interface Region {
80 identifier?: string;
81 latitude: number;
82 longitude: number;
83 radius: number;
84 notifyOnEnter?: boolean;
85 notifyOnExit?: boolean;
86}
87
88type Subscription = {
89 remove: () => void;
90};
91type LocationCallback = (data: LocationData) => any;
92type HeadingCallback = (data: HeadingData) => any;
93
94enum LocationAccuracy {
95 Lowest = 1,
96 Low = 2,
97 Balanced = 3,
98 High = 4,
99 Highest = 5,
100 BestForNavigation = 6,
101}
102
103enum LocationActivityType {
104 Other = 1,
105 AutomotiveNavigation = 2,
106 Fitness = 3,
107 OtherNavigation = 4,
108 Airborne = 5,
109}
110
111export { LocationAccuracy as Accuracy, LocationActivityType as ActivityType };
112
113export enum GeofencingEventType {
114 Enter = 1,
115 Exit = 2,
116}
117
118export enum GeofencingRegionState {
119 Unknown = 0,
120 Inside = 1,
121 Outside = 2,
122}
123
124let nextWatchId = 0;
125let headingId;
126function _getNextWatchId() {
127 nextWatchId++;
128 return nextWatchId;
129}
130function _getCurrentWatchId() {
131 return nextWatchId;
132}
133
134let watchCallbacks: {
135 [watchId: number]: LocationCallback | HeadingCallback;
136} = {};
137
138let deviceEventSubscription: Subscription | null;
139let headingEventSub: Subscription | null;
140let googleApiKey;
141const googleApiUrl = 'https://maps.googleapis.com/maps/api/geocode/json';
142
143export async function getProviderStatusAsync(): Promise<ProviderStatus> {
144 return ExpoLocation.getProviderStatusAsync();
145}
146
147export async function enableNetworkProviderAsync(): Promise<void> {
148 // If network provider is disabled (user's location mode is set to "Device only"),
149 // Android's location provider may not give you any results. Use this method in order to ask the user
150 // to change the location mode to "High accuracy" which uses Google Play services and enables network provider.
151 // `getCurrentPositionAsync` and `watchPositionAsync` are doing it automatically anyway.
152
153 if (Platform.OS === 'android') {
154 return ExpoLocation.enableNetworkProviderAsync();
155 }
156}
157
158export async function getCurrentPositionAsync(
159 options: LocationOptions = {}
160): Promise<LocationData> {
161 return ExpoLocation.getCurrentPositionAsync(options);
162}
163
164// Start Compass Module
165
166// To simplify, we will call watchHeadingAsync and wait for one update To ensure accuracy, we wait
167// for a couple of watch updates if the data has low accuracy
168export async function getHeadingAsync(): Promise<HeadingData> {
169 return new Promise<HeadingData>(async (resolve, reject) => {
170 try {
171 // If there is already a compass active (would be a watch)
172 if (headingEventSub) {
173 let tries = 0;
174 const headingSub = LocationEventEmitter.addListener(
175 'Expo.headingChanged',
176 ({ heading }: { heading: HeadingData }) => {
177 if (heading.accuracy > 1 || tries > 5) {
178 resolve(heading);
179 LocationEventEmitter.removeSubscription(headingSub);
180 } else {
181 tries += 1;
182 }
183 }
184 );
185 } else {
186 let done = false;
187 let subscription;
188 let tries = 0;
189 subscription = await watchHeadingAsync((heading: HeadingData) => {
190 if (!done) {
191 if (heading.accuracy > 1 || tries > 5) {
192 subscription.remove();
193 resolve(heading);
194 done = true;
195 } else {
196 tries += 1;
197 }
198 } else {
199 subscription.remove();
200 }
201 });
202
203 if (done) {
204 subscription.remove();
205 }
206 }
207 } catch (e) {
208 reject(e);
209 }
210 });
211}
212
213export async function watchHeadingAsync(
214 callback: HeadingCallback
215): Promise<{ remove: () => void }> {
216 // Check if there is already a compass event watch.
217 if (headingEventSub) {
218 _removeHeadingWatcher(headingId);
219 }
220
221 headingEventSub = LocationEventEmitter.addListener(
222 'Expo.headingChanged',
223 ({ watchId, heading }: { watchId: string; heading: HeadingData }) => {
224 const callback = watchCallbacks[watchId];
225 if (callback) {
226 callback(heading);
227 } else {
228 ExpoLocation.removeWatchAsync(watchId);
229 }
230 }
231 );
232
233 headingId = _getNextWatchId();
234 watchCallbacks[headingId] = callback;
235 await ExpoLocation.watchDeviceHeading(headingId);
236 return {
237 remove() {
238 _removeHeadingWatcher(headingId);
239 },
240 };
241}
242
243// Removes the compass listener and sub from JS and Native
244function _removeHeadingWatcher(watchId) {
245 if (!watchCallbacks[watchId]) {
246 return;
247 }
248 delete watchCallbacks[watchId];
249 ExpoLocation.removeWatchAsync(watchId);
250 if (headingEventSub) {
251 LocationEventEmitter.removeSubscription(headingEventSub);
252 headingEventSub = null;
253 }
254}
255// End Compass Module
256
257function _maybeInitializeEmitterSubscription() {
258 if (!deviceEventSubscription) {
259 deviceEventSubscription = LocationEventEmitter.addListener(
260 'Expo.locationChanged',
261 ({ watchId, location }: { watchId: string; location: LocationData }) => {
262 const callback = watchCallbacks[watchId];
263 if (callback) {
264 callback(location);
265 } else {
266 ExpoLocation.removeWatchAsync(watchId);
267 }
268 }
269 );
270 }
271}
272
273export async function geocodeAsync(address: string): Promise<Array<GeocodedLocation>> {
274 return ExpoLocation.geocodeAsync(address).catch(error => {
275 const platformUsesGoogleMaps = Platform.OS === 'android' || Platform.OS === 'web';
276
277 if (platformUsesGoogleMaps && error.code === 'E_NO_GEOCODER') {
278 if (!googleApiKey) {
279 throw new CodedError(
280 error.code,
281 `${error.message} Please set a Google API Key to use geocoding.`
282 );
283 }
284 return _googleGeocodeAsync(address);
285 }
286 throw error;
287 });
288}
289
290export async function reverseGeocodeAsync(location: {
291 latitude: number;
292 longitude: number;
293}): Promise<Address[]> {
294 if (typeof location.latitude !== 'number' || typeof location.longitude !== 'number') {
295 throw new TypeError(
296 'Location should be an object with number properties `latitude` and `longitude`.'
297 );
298 }
299 return ExpoLocation.reverseGeocodeAsync(location).catch(error => {
300 const platformUsesGoogleMaps = Platform.OS === 'android' || Platform.OS === 'web';
301
302 if (platformUsesGoogleMaps && error.code === 'E_NO_GEOCODER') {
303 if (!googleApiKey) {
304 throw new CodedError(
305 error.code,
306 `${error.message} Please set a Google API Key to use geocoding.`
307 );
308 }
309 return _googleReverseGeocodeAsync(location);
310 }
311 throw error;
312 });
313}
314
315export function setApiKey(apiKey: string) {
316 googleApiKey = apiKey;
317}
318
319async function _googleGeocodeAsync(address: string): Promise<GeocodedLocation[]> {
320 const result = await fetch(`${googleApiUrl}?key=${googleApiKey}&address=${encodeURI(address)}`);
321 const resultObject = await result.json();
322
323 if (resultObject.status === 'ZERO_RESULTS') {
324 return [];
325 }
326
327 assertGeocodeResults(resultObject);
328
329 return resultObject.results.map(result => {
330 let location = result.geometry.location;
331 // TODO: This is missing a lot of props
332 return {
333 latitude: location.lat,
334 longitude: location.lng,
335 };
336 });
337}
338
339async function _googleReverseGeocodeAsync(options: {
340 latitude: number;
341 longitude: number;
342}): Promise<Address[]> {
343 const result = await fetch(
344 `${googleApiUrl}?key=${googleApiKey}&latlng=${options.latitude},${options.longitude}`
345 );
346 const resultObject = await result.json();
347
348 if (resultObject.status === 'ZERO_RESULTS') {
349 return [];
350 }
351
352 assertGeocodeResults(resultObject);
353
354 return resultObject.results.map(result => {
355 const address: any = {};
356
357 result.address_components.forEach(component => {
358 if (component.types.includes('locality')) {
359 address.city = component.long_name;
360 } else if (component.types.includes('street_address')) {
361 address.street = component.long_name;
362 } else if (component.types.includes('administrative_area_level_1')) {
363 address.region = component.long_name;
364 } else if (component.types.includes('country')) {
365 address.country = component.long_name;
366 } else if (component.types.includes('postal_code')) {
367 address.postalCode = component.long_name;
368 } else if (component.types.includes('point_of_interest')) {
369 address.name = component.long_name;
370 }
371 });
372 return address as Address;
373 });
374}
375
376// https://developers.google.com/maps/documentation/geocoding/intro
377function assertGeocodeResults(resultObject: any): void {
378 const { status, error_message } = resultObject;
379 if (status !== 'ZERO_RESULTS' && status !== 'OK') {
380 if (error_message) {
381 throw new CodedError(status, error_message);
382 } else if (status === 'UNKNOWN_ERROR') {
383 throw new CodedError(
384 status,
385 'the request could not be processed due to a server error. The request may succeed if you try again.'
386 );
387 }
388 throw new CodedError(status, `An error occurred during geocoding.`);
389 }
390}
391
392// Polyfill: navigator.geolocation.watchPosition
393function watchPosition(
394 success: GeoSuccessCallback,
395 error: GeoErrorCallback,
396 options: LocationOptions
397) {
398 _maybeInitializeEmitterSubscription();
399
400 const watchId = _getNextWatchId();
401 watchCallbacks[watchId] = success;
402
403 ExpoLocation.watchPositionImplAsync(watchId, options).catch(err => {
404 _removeWatcher(watchId);
405 error({ watchId, message: err.message, code: err.code });
406 });
407
408 return watchId;
409}
410
411export async function watchPositionAsync(options: LocationOptions, callback: LocationCallback) {
412 _maybeInitializeEmitterSubscription();
413
414 const watchId = _getNextWatchId();
415 watchCallbacks[watchId] = callback;
416 await ExpoLocation.watchPositionImplAsync(watchId, options);
417
418 return {
419 remove() {
420 _removeWatcher(watchId);
421 },
422 };
423}
424
425// Polyfill: navigator.geolocation.clearWatch
426function clearWatch(watchId: number) {
427 _removeWatcher(watchId);
428}
429
430function _removeWatcher(watchId) {
431 // Do nothing if we have already removed the subscription
432 if (!watchCallbacks[watchId]) {
433 return;
434 }
435
436 ExpoLocation.removeWatchAsync(watchId);
437 delete watchCallbacks[watchId];
438 if (Object.keys(watchCallbacks).length === 0 && deviceEventSubscription) {
439 LocationEventEmitter.removeSubscription(deviceEventSubscription);
440 deviceEventSubscription = null;
441 }
442}
443
444type GeoSuccessCallback = (data: LocationData) => void;
445type GeoErrorCallback = (error: any) => void;
446
447function getCurrentPosition(
448 success: GeoSuccessCallback,
449 error: GeoErrorCallback = () => {},
450 options: LocationOptions = {}
451): void {
452 invariant(typeof success === 'function', 'Must provide a valid success callback.');
453
454 invariant(typeof options === 'object', 'options must be an object.');
455
456 _getCurrentPositionAsyncWrapper(success, error, options);
457}
458
459// This function exists to let us continue to return undefined from getCurrentPosition, while still
460// using async/await for the internal implementation of it
461async function _getCurrentPositionAsyncWrapper(
462 success: GeoSuccessCallback,
463 error: GeoErrorCallback,
464 options: LocationOptions
465): Promise<any> {
466 try {
467 await ExpoLocation.requestPermissionsAsync();
468 const result = await getCurrentPositionAsync(options);
469 success(result);
470 } catch (e) {
471 error(e);
472 }
473}
474
475export async function requestPermissionsAsync(): Promise<void> {
476 await ExpoLocation.requestPermissionsAsync();
477}
478
479// --- Location service
480
481export async function hasServicesEnabledAsync(): Promise<boolean> {
482 return await ExpoLocation.hasServicesEnabledAsync();
483}
484
485// --- Background location updates
486
487function _validateTaskName(taskName: string) {
488 invariant(taskName && typeof taskName === 'string', '`taskName` must be a non-empty string.');
489}
490
491export async function isBackgroundLocationAvailableAsync(): Promise<boolean> {
492 const providerStatus = await getProviderStatusAsync();
493 return providerStatus.backgroundModeEnabled;
494}
495
496export async function startLocationUpdatesAsync(
497 taskName: string,
498 options: LocationTaskOptions = { accuracy: LocationAccuracy.Balanced }
499): Promise<void> {
500 _validateTaskName(taskName);
501 await ExpoLocation.startLocationUpdatesAsync(taskName, options);
502}
503
504export async function stopLocationUpdatesAsync(taskName: string): Promise<void> {
505 _validateTaskName(taskName);
506 await ExpoLocation.stopLocationUpdatesAsync(taskName);
507}
508
509export async function hasStartedLocationUpdatesAsync(taskName: string): Promise<boolean> {
510 _validateTaskName(taskName);
511 return ExpoLocation.hasStartedLocationUpdatesAsync(taskName);
512}
513
514// --- Geofencing
515
516function _validateRegions(regions: Array<Region>) {
517 if (!regions || regions.length === 0) {
518 throw new Error(
519 'Regions array cannot be empty. Use `stopGeofencingAsync` if you want to stop geofencing all regions'
520 );
521 }
522 for (const region of regions) {
523 if (typeof region.latitude !== 'number') {
524 throw new TypeError(`Region's latitude must be a number. Got '${region.latitude}' instead.`);
525 }
526 if (typeof region.longitude !== 'number') {
527 throw new TypeError(
528 `Region's longitude must be a number. Got '${region.longitude}' instead.`
529 );
530 }
531 if (typeof region.radius !== 'number') {
532 throw new TypeError(`Region's radius must be a number. Got '${region.radius}' instead.`);
533 }
534 }
535}
536
537export async function startGeofencingAsync(
538 taskName: string,
539 regions: Array<Region> = []
540): Promise<void> {
541 _validateTaskName(taskName);
542 _validateRegions(regions);
543 await ExpoLocation.startGeofencingAsync(taskName, { regions });
544}
545
546export async function stopGeofencingAsync(taskName: string): Promise<void> {
547 _validateTaskName(taskName);
548 await ExpoLocation.stopGeofencingAsync(taskName);
549}
550
551export async function hasStartedGeofencingAsync(taskName: string): Promise<boolean> {
552 _validateTaskName(taskName);
553 return ExpoLocation.hasStartedGeofencingAsync(taskName);
554}
555
556export function installWebGeolocationPolyfill(): void {
557 if (Platform.OS !== 'web') {
558 // Polyfill navigator.geolocation for interop with the core react-native and web API approach to
559 // geolocation
560 // @ts-ignore
561 window.navigator.geolocation = {
562 getCurrentPosition,
563 watchPosition,
564 clearWatch,
565
566 // We don't polyfill stopObserving, this is an internal method that probably should not even exist
567 // in react-native docs
568 stopObserving: () => {},
569 };
570 }
571}
572
573export {
574 // For internal purposes
575 LocationEventEmitter as EventEmitter,
576 _getCurrentWatchId,
577};