UNPKG

11.3 kBJavaScriptView Raw
1// @flow
2import invariant from 'invariant';
3import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
4
5import * as Permissions from './Permissions';
6
7const LocationEventEmitter = new NativeEventEmitter(NativeModules.ExponentLocation);
8
9type ProviderStatus = {
10 locationServicesEnabled: boolean,
11 gpsAvailable: ?boolean,
12 networkAvailable: ?boolean,
13 passiveAvailable: ?boolean,
14};
15
16type LocationOptions = {
17 enableHighAccuracy?: boolean,
18 timeInterval?: number,
19 distanceInterval?: number,
20};
21
22type LocationData = {
23 coords: {
24 latitude: number,
25 longitude: number,
26 altitude: number,
27 accuracy: number,
28 heading: number,
29 speed: number,
30 },
31 timestamp: number,
32};
33
34type HeadingData = {
35 trueHeading: number,
36 magHeading: number,
37 accuracy: number,
38};
39
40type LocationCallback = (data: LocationData) => any;
41type HeadingCallback = (data: HeadingData) => any;
42
43const { ExponentLocation } = NativeModules;
44
45let nextWatchId = 0;
46let headingId;
47function _getNextWatchId() {
48 nextWatchId++;
49 return nextWatchId;
50}
51function _getCurrentWatchId() {
52 return nextWatchId;
53}
54
55let watchCallbacks: {
56 [watchId: number]: LocationCallback | HeadingCallback,
57} = {};
58let deviceEventSubscription: ?Function;
59let headingEventSub: ?Function;
60let googleApiKey;
61const googleApiUrl = 'https://maps.googleapis.com/maps/api/geocode/json';
62
63function getProviderStatusAsync(): Promise<ProviderStatus> {
64 return ExponentLocation.getProviderStatusAsync();
65}
66
67function getCurrentPositionAsync(options: LocationOptions): Promise<LocationData> {
68 // On Android we have a native method for this case.
69 if (Platform.OS === 'android') {
70 return ExponentLocation.getCurrentPositionAsync(options);
71 }
72
73 // On iOS we implement it in terms of `.watchPositionAsync(...)`
74 // TODO: Use separate native method for iOS too?
75 return new Promise(async (resolve, reject) => {
76 try {
77 let done = false; // To make sure we only resolve once.
78 let subscription;
79 subscription = await watchPositionAsync(options, location => {
80 if (!done) {
81 resolve(location);
82 done = true;
83 }
84 subscription.remove();
85 });
86
87 // In case the callback is fired before we get here.
88 if (done) {
89 subscription.remove();
90 }
91 } catch (e) {
92 reject(e);
93 }
94 });
95}
96
97// Start Compass Module
98
99// To simplify, we will call watchHeadingAsync and wait for one update To ensure accuracy, we wait
100// for a couple of watch updates if the data has low accuracy
101async function getHeadingAsync() {
102 return new Promise(async (resolve, reject) => {
103 try {
104 // If there is already a compass active (would be a watch)
105 if (headingEventSub) {
106 let tries = 0;
107 const headingSub = LocationEventEmitter.addListener(
108 'Exponent.headingChanged',
109 ({ watchId, heading }) => {
110 if (heading.accuracy > 1 || tries > 5) {
111 resolve(heading);
112 LocationEventEmitter.removeSubscription(headingSub);
113 } else {
114 tries += 1;
115 }
116 }
117 );
118 } else {
119 let done = false;
120 let subscription;
121 let tries = 0;
122 subscription = await watchHeadingAsync(heading => {
123 if (!done) {
124 if (heading.accuracy > 1 || tries > 5) {
125 subscription.remove();
126 resolve(heading);
127 done = true;
128 } else {
129 tries += 1;
130 }
131 } else {
132 subscription.remove();
133 }
134 });
135
136 if (done) {
137 subscription.remove();
138 }
139 }
140 } catch (e) {
141 reject(e);
142 }
143 });
144}
145
146async function watchHeadingAsync(callback: HeadingCallback) {
147 // Check if there is already a compass event watch.
148 if (headingEventSub) {
149 _removeHeadingWatcher(headingId);
150 }
151
152 headingEventSub = LocationEventEmitter.addListener(
153 'Exponent.headingChanged',
154 ({ watchId, heading }) => {
155 const callback = watchCallbacks[watchId];
156 if (callback) {
157 callback(heading);
158 } else {
159 ExponentLocation.removeWatchAsync(watchId);
160 }
161 }
162 );
163
164 headingId = _getNextWatchId();
165 watchCallbacks[headingId] = callback;
166 await ExponentLocation.watchDeviceHeading(headingId);
167 return {
168 remove() {
169 _removeHeadingWatcher(headingId);
170 },
171 };
172}
173
174// Removes the compass listener and sub from JS and Native
175function _removeHeadingWatcher(watchId) {
176 if (!watchCallbacks[watchId]) {
177 return;
178 }
179 delete watchCallbacks[watchId];
180 ExponentLocation.removeWatchAsync(watchId);
181 LocationEventEmitter.removeSubscription(headingEventSub);
182 headingEventSub = null;
183}
184// End Compass Module
185
186function _maybeInitializeEmitterSubscription() {
187 if (!deviceEventSubscription) {
188 deviceEventSubscription = LocationEventEmitter.addListener(
189 'Exponent.locationChanged',
190 ({ watchId, location }) => {
191 const callback = watchCallbacks[watchId];
192 if (callback) {
193 callback(location);
194 } else {
195 ExponentLocation.removeWatchAsync(watchId);
196 }
197 }
198 );
199 }
200}
201
202async function _askPermissionForWatchAsync(success, error, options, watchId) {
203 let { status } = await Permissions.askAsync(Permissions.LOCATION);
204 if (status === 'granted') {
205 ExponentLocation.watchPositionImplAsync(watchId, options);
206 } else {
207 _removeWatcher(watchId);
208 error({ watchId, message: 'No permission to access location' });
209 }
210}
211
212async function geocodeAsync(address: string) {
213 return ExponentLocation.geocodeAsync(address).catch(error => {
214 if (Platform.OS === 'android' && error.code === 'E_NO_GEOCODER') {
215 if (!googleApiKey) {
216 throw new Error(error.message + ' Please set a Google API Key to use geocoding.');
217 }
218 return _googleGeocodeAsync(address);
219 }
220 throw error;
221 });
222}
223
224async function reverseGeocodeAsync(location: { latitude: number, longitude: number }) {
225 if (typeof location.latitude !== 'number' || typeof location.longitude !== 'number') {
226 throw new TypeError(
227 'Location should be an object with number properties `latitude` and `longitude`.'
228 );
229 }
230 return ExponentLocation.reverseGeocodeAsync(location).catch(error => {
231 if (Platform.OS === 'android' && error.code === 'E_NO_GEOCODER') {
232 if (!googleApiKey) {
233 throw new Error(error.message + ' Please set a Google API Key to use geocoding.');
234 }
235 return _googleReverseGeocodeAsync(location);
236 }
237 throw error;
238 });
239}
240
241function setApiKey(apiKey: string) {
242 googleApiKey = apiKey;
243}
244
245async function _googleGeocodeAsync(address: string) {
246 const result = await fetch(`${googleApiUrl}?key=${googleApiKey}&address=${encodeURI(address)}`);
247 const resultObject = await result.json();
248
249 if (resultObject.status !== 'OK') {
250 throw new Error('An error occurred during geocoding.');
251 }
252
253 return resultObject.results.map(result => {
254 let location = result.geometry.location;
255 return {
256 latitude: location.lat,
257 longitude: location.lng,
258 };
259 });
260}
261
262async function _googleReverseGeocodeAsync(options: { latitude: number, longitude: number }) {
263 const result = await fetch(
264 `${googleApiUrl}?key=${googleApiKey}&latlng=${options.latitude},${options.longitude}`
265 );
266 const resultObject = await result.json();
267
268 if (resultObject.status !== 'OK') {
269 throw new Error('An error occurred during geocoding.');
270 }
271
272 return resultObject.results.map(result => {
273 let address = {};
274 result.address_components.forEach(component => {
275 if (component.types.includes('locality')) {
276 address.city = component.long_name;
277 } else if (component.types.includes('street_address')) {
278 address.street = component.long_name;
279 } else if (component.types.includes('administrative_area_level_1')) {
280 address.region = component.long_name;
281 } else if (component.types.includes('country')) {
282 address.country = component.long_name;
283 } else if (component.types.includes('postal_code')) {
284 address.postalCode = component.long_name;
285 } else if (component.types.includes('point_of_interest')) {
286 address.name = component.long_name;
287 }
288 });
289 return address;
290 });
291}
292
293// Polyfill: navigator.geolocation.watchPosition
294function watchPosition(
295 success: GeoSuccessCallback,
296 error: GeoErrorCallback,
297 options: LocationOptions
298) {
299 _maybeInitializeEmitterSubscription();
300
301 const watchId = _getNextWatchId();
302 watchCallbacks[watchId] = success;
303 _askPermissionForWatchAsync(success, error, options, watchId);
304
305 return watchId;
306}
307
308async function watchPositionAsync(options: LocationOptions, callback: LocationCallback) {
309 _maybeInitializeEmitterSubscription();
310
311 const watchId = _getNextWatchId();
312 watchCallbacks[watchId] = callback;
313 await ExponentLocation.watchPositionImplAsync(watchId, options);
314
315 return {
316 remove() {
317 _removeWatcher(watchId);
318 },
319 };
320}
321
322// Polyfill: navigator.geolocation.clearWatch
323function clearWatch(watchId: number) {
324 _removeWatcher(watchId);
325}
326
327function _removeWatcher(watchId) {
328 // Do nothing if we have already removed the subscription
329 if (!watchCallbacks[watchId]) {
330 return;
331 }
332
333 ExponentLocation.removeWatchAsync(watchId);
334 delete watchCallbacks[watchId];
335 if (Object.keys(watchCallbacks).length === 0) {
336 LocationEventEmitter.removeSubscription(deviceEventSubscription);
337 deviceEventSubscription = null;
338 }
339}
340
341type GeoSuccessCallback = (data: LocationData) => void;
342type GeoErrorCallback = (error: any) => void;
343
344function getCurrentPosition(
345 success: GeoSuccessCallback,
346 error?: GeoErrorCallback = () => {},
347 options?: LocationOptions = {}
348): void {
349 invariant(typeof success === 'function', 'Must provide a valid success callback.');
350
351 invariant(typeof options === 'object', 'options must be an object.');
352
353 _getCurrentPositionAsyncWrapper(success, error, options);
354}
355
356// This function exists to let us continue to return undefined from getCurrentPosition, while still
357// using async/await for the internal implementation of it
358async function _getCurrentPositionAsyncWrapper(
359 success: GeoSuccessCallback,
360 error: GeoErrorCallback,
361 options: LocationOptions
362): Promise<*> {
363 try {
364 let { status } = await Permissions.askAsync(Permissions.LOCATION);
365 if (status !== 'granted') {
366 throw new Error(
367 'Permission to access location not granted. User must now enable it manually in settings'
368 );
369 }
370
371 let result = await Location.getCurrentPositionAsync(options);
372 success(result);
373 } catch (e) {
374 error(e);
375 }
376}
377
378// Polyfill navigator.geolocation for interop with the core react-native and web API approach to
379// geolocation
380window.navigator.geolocation = {
381 getCurrentPosition,
382 watchPosition,
383 clearWatch,
384
385 // We don't polyfill stopObserving, this is an internal method that probably should not even exist
386 // in react-native docs
387 stopObserving: () => {},
388};
389
390const Location = {
391 getProviderStatusAsync,
392 getCurrentPositionAsync,
393 watchPositionAsync,
394 getHeadingAsync,
395 watchHeadingAsync,
396 geocodeAsync,
397 reverseGeocodeAsync,
398 setApiKey,
399
400 // For internal purposes
401 EventEmitter: LocationEventEmitter,
402 _getCurrentWatchId,
403};
404
405export default Location;