1 | import { EventEmitter, Platform, CodedError } from '@unimodules/core';
|
2 | import invariant from 'invariant';
|
3 |
|
4 | import ExpoLocation from './ExpoLocation';
|
5 |
|
6 | const LocationEventEmitter = new EventEmitter(ExpoLocation);
|
7 |
|
8 | export interface ProviderStatus {
|
9 | locationServicesEnabled: boolean;
|
10 | backgroundModeEnabled: boolean;
|
11 | gpsAvailable?: boolean;
|
12 | networkAvailable?: boolean;
|
13 | passiveAvailable?: boolean;
|
14 | }
|
15 |
|
16 | export interface LocationOptions {
|
17 | accuracy?: LocationAccuracy;
|
18 | enableHighAccuracy?: boolean;
|
19 | timeInterval?: number;
|
20 | distanceInterval?: number;
|
21 | timeout?: number;
|
22 | mayShowUserSettingsDialog?: boolean;
|
23 | }
|
24 |
|
25 | export 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 |
|
37 | export interface HeadingData {
|
38 | trueHeading: number;
|
39 | magHeading: number;
|
40 | accuracy: number;
|
41 | }
|
42 |
|
43 | export interface GeocodedLocation {
|
44 | latitude: number;
|
45 | longitude: number;
|
46 | altitude?: number;
|
47 | accuracy?: number;
|
48 | }
|
49 |
|
50 | export interface Address {
|
51 | city: string;
|
52 | street: string;
|
53 | region: string;
|
54 | country: string;
|
55 | postalCode: string;
|
56 | name: string;
|
57 | }
|
58 |
|
59 | interface LocationTaskOptions {
|
60 | accuracy?: LocationAccuracy;
|
61 | timeInterval?: number;
|
62 | distanceInterval?: number;
|
63 | showsBackgroundLocationIndicator?: boolean;
|
64 | deferredUpdatesDistance?: number;
|
65 | deferredUpdatesTimeout?: number;
|
66 | deferredUpdatesInterval?: number;
|
67 |
|
68 |
|
69 | activityType?: LocationActivityType;
|
70 | pausesUpdatesAutomatically?: boolean;
|
71 |
|
72 | foregroundService?: {
|
73 | notificationTitle: string;
|
74 | notificationBody: string;
|
75 | notificationColor?: string;
|
76 | };
|
77 | }
|
78 |
|
79 | interface Region {
|
80 | identifier?: string;
|
81 | latitude: number;
|
82 | longitude: number;
|
83 | radius: number;
|
84 | notifyOnEnter?: boolean;
|
85 | notifyOnExit?: boolean;
|
86 | }
|
87 |
|
88 | type Subscription = {
|
89 | remove: () => void;
|
90 | };
|
91 | type LocationCallback = (data: LocationData) => any;
|
92 | type HeadingCallback = (data: HeadingData) => any;
|
93 |
|
94 | enum LocationAccuracy {
|
95 | Lowest = 1,
|
96 | Low = 2,
|
97 | Balanced = 3,
|
98 | High = 4,
|
99 | Highest = 5,
|
100 | BestForNavigation = 6,
|
101 | }
|
102 |
|
103 | enum LocationActivityType {
|
104 | Other = 1,
|
105 | AutomotiveNavigation = 2,
|
106 | Fitness = 3,
|
107 | OtherNavigation = 4,
|
108 | Airborne = 5,
|
109 | }
|
110 |
|
111 | export { LocationAccuracy as Accuracy, LocationActivityType as ActivityType };
|
112 |
|
113 | export enum GeofencingEventType {
|
114 | Enter = 1,
|
115 | Exit = 2,
|
116 | }
|
117 |
|
118 | export enum GeofencingRegionState {
|
119 | Unknown = 0,
|
120 | Inside = 1,
|
121 | Outside = 2,
|
122 | }
|
123 |
|
124 | let nextWatchId = 0;
|
125 | let headingId;
|
126 | function _getNextWatchId() {
|
127 | nextWatchId++;
|
128 | return nextWatchId;
|
129 | }
|
130 | function _getCurrentWatchId() {
|
131 | return nextWatchId;
|
132 | }
|
133 |
|
134 | let watchCallbacks: {
|
135 | [watchId: number]: LocationCallback | HeadingCallback;
|
136 | } = {};
|
137 |
|
138 | let deviceEventSubscription: Subscription | null;
|
139 | let headingEventSub: Subscription | null;
|
140 | let googleApiKey;
|
141 | const googleApiUrl = 'https://maps.googleapis.com/maps/api/geocode/json';
|
142 |
|
143 | export async function getProviderStatusAsync(): Promise<ProviderStatus> {
|
144 | return ExpoLocation.getProviderStatusAsync();
|
145 | }
|
146 |
|
147 | export async function enableNetworkProviderAsync(): Promise<void> {
|
148 |
|
149 |
|
150 |
|
151 |
|
152 |
|
153 | if (Platform.OS === 'android') {
|
154 | return ExpoLocation.enableNetworkProviderAsync();
|
155 | }
|
156 | }
|
157 |
|
158 | export async function getCurrentPositionAsync(
|
159 | options: LocationOptions = {}
|
160 | ): Promise<LocationData> {
|
161 | return ExpoLocation.getCurrentPositionAsync(options);
|
162 | }
|
163 |
|
164 |
|
165 |
|
166 |
|
167 |
|
168 | export async function getHeadingAsync(): Promise<HeadingData> {
|
169 | return new Promise<HeadingData>(async (resolve, reject) => {
|
170 | try {
|
171 |
|
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 |
|
213 | export async function watchHeadingAsync(
|
214 | callback: HeadingCallback
|
215 | ): Promise<{ remove: () => void }> {
|
216 |
|
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 |
|
244 | function _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 |
|
256 |
|
257 | function _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 |
|
273 | export 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 |
|
290 | export 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 |
|
315 | export function setApiKey(apiKey: string) {
|
316 | googleApiKey = apiKey;
|
317 | }
|
318 |
|
319 | async 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 |
|
332 | return {
|
333 | latitude: location.lat,
|
334 | longitude: location.lng,
|
335 | };
|
336 | });
|
337 | }
|
338 |
|
339 | async 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 |
|
377 | function 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 |
|
393 | function 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 |
|
411 | export 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 |
|
426 | function clearWatch(watchId: number) {
|
427 | _removeWatcher(watchId);
|
428 | }
|
429 |
|
430 | function _removeWatcher(watchId) {
|
431 |
|
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 |
|
444 | type GeoSuccessCallback = (data: LocationData) => void;
|
445 | type GeoErrorCallback = (error: any) => void;
|
446 |
|
447 | function 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 |
|
460 |
|
461 | async 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 |
|
475 | export async function requestPermissionsAsync(): Promise<void> {
|
476 | await ExpoLocation.requestPermissionsAsync();
|
477 | }
|
478 |
|
479 |
|
480 |
|
481 | export async function hasServicesEnabledAsync(): Promise<boolean> {
|
482 | return await ExpoLocation.hasServicesEnabledAsync();
|
483 | }
|
484 |
|
485 |
|
486 |
|
487 | function _validateTaskName(taskName: string) {
|
488 | invariant(taskName && typeof taskName === 'string', '`taskName` must be a non-empty string.');
|
489 | }
|
490 |
|
491 | export async function isBackgroundLocationAvailableAsync(): Promise<boolean> {
|
492 | const providerStatus = await getProviderStatusAsync();
|
493 | return providerStatus.backgroundModeEnabled;
|
494 | }
|
495 |
|
496 | export 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 |
|
504 | export async function stopLocationUpdatesAsync(taskName: string): Promise<void> {
|
505 | _validateTaskName(taskName);
|
506 | await ExpoLocation.stopLocationUpdatesAsync(taskName);
|
507 | }
|
508 |
|
509 | export async function hasStartedLocationUpdatesAsync(taskName: string): Promise<boolean> {
|
510 | _validateTaskName(taskName);
|
511 | return ExpoLocation.hasStartedLocationUpdatesAsync(taskName);
|
512 | }
|
513 |
|
514 |
|
515 |
|
516 | function _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 |
|
537 | export 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 |
|
546 | export async function stopGeofencingAsync(taskName: string): Promise<void> {
|
547 | _validateTaskName(taskName);
|
548 | await ExpoLocation.stopGeofencingAsync(taskName);
|
549 | }
|
550 |
|
551 | export async function hasStartedGeofencingAsync(taskName: string): Promise<boolean> {
|
552 | _validateTaskName(taskName);
|
553 | return ExpoLocation.hasStartedGeofencingAsync(taskName);
|
554 | }
|
555 |
|
556 | export function installWebGeolocationPolyfill(): void {
|
557 | if (Platform.OS !== 'web') {
|
558 |
|
559 |
|
560 |
|
561 | window.navigator.geolocation = {
|
562 | getCurrentPosition,
|
563 | watchPosition,
|
564 | clearWatch,
|
565 |
|
566 |
|
567 |
|
568 | stopObserving: () => {},
|
569 | };
|
570 | }
|
571 | }
|
572 |
|
573 | export {
|
574 |
|
575 | LocationEventEmitter as EventEmitter,
|
576 | _getCurrentWatchId,
|
577 | };
|