1 |
|
2 | import invariant from 'invariant';
|
3 | import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
|
4 |
|
5 | import * as Permissions from './Permissions';
|
6 |
|
7 | const LocationEventEmitter = new NativeEventEmitter(NativeModules.ExponentLocation);
|
8 |
|
9 | type ProviderStatus = {
|
10 | locationServicesEnabled: boolean,
|
11 | gpsAvailable: ?boolean,
|
12 | networkAvailable: ?boolean,
|
13 | passiveAvailable: ?boolean,
|
14 | };
|
15 |
|
16 | type LocationOptions = {
|
17 | enableHighAccuracy?: boolean,
|
18 | timeInterval?: number,
|
19 | distanceInterval?: number,
|
20 | };
|
21 |
|
22 | type 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 |
|
34 | type HeadingData = {
|
35 | trueHeading: number,
|
36 | magHeading: number,
|
37 | accuracy: number,
|
38 | };
|
39 |
|
40 | type LocationCallback = (data: LocationData) => any;
|
41 | type HeadingCallback = (data: HeadingData) => any;
|
42 |
|
43 | const { ExponentLocation } = NativeModules;
|
44 |
|
45 | let nextWatchId = 0;
|
46 | let headingId;
|
47 | function _getNextWatchId() {
|
48 | nextWatchId++;
|
49 | return nextWatchId;
|
50 | }
|
51 | function _getCurrentWatchId() {
|
52 | return nextWatchId;
|
53 | }
|
54 |
|
55 | let watchCallbacks: {
|
56 | [watchId: number]: LocationCallback | HeadingCallback,
|
57 | } = {};
|
58 | let deviceEventSubscription: ?Function;
|
59 | let headingEventSub: ?Function;
|
60 | let googleApiKey;
|
61 | const googleApiUrl = 'https://maps.googleapis.com/maps/api/geocode/json';
|
62 |
|
63 | function getProviderStatusAsync(): Promise<ProviderStatus> {
|
64 | return ExponentLocation.getProviderStatusAsync();
|
65 | }
|
66 |
|
67 | function getCurrentPositionAsync(options: LocationOptions): Promise<LocationData> {
|
68 |
|
69 | if (Platform.OS === 'android') {
|
70 | return ExponentLocation.getCurrentPositionAsync(options);
|
71 | }
|
72 |
|
73 |
|
74 |
|
75 | return new Promise(async (resolve, reject) => {
|
76 | try {
|
77 | let done = false;
|
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 |
|
88 | if (done) {
|
89 | subscription.remove();
|
90 | }
|
91 | } catch (e) {
|
92 | reject(e);
|
93 | }
|
94 | });
|
95 | }
|
96 |
|
97 |
|
98 |
|
99 |
|
100 |
|
101 | async function getHeadingAsync() {
|
102 | return new Promise(async (resolve, reject) => {
|
103 | try {
|
104 |
|
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 |
|
146 | async function watchHeadingAsync(callback: HeadingCallback) {
|
147 |
|
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 |
|
175 | function _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 |
|
185 |
|
186 | function _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 |
|
202 | async 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 |
|
212 | async 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 |
|
224 | async 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 |
|
241 | function setApiKey(apiKey: string) {
|
242 | googleApiKey = apiKey;
|
243 | }
|
244 |
|
245 | async 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 |
|
262 | async 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 |
|
294 | function 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 |
|
308 | async 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 |
|
323 | function clearWatch(watchId: number) {
|
324 | _removeWatcher(watchId);
|
325 | }
|
326 |
|
327 | function _removeWatcher(watchId) {
|
328 |
|
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 |
|
341 | type GeoSuccessCallback = (data: LocationData) => void;
|
342 | type GeoErrorCallback = (error: any) => void;
|
343 |
|
344 | function 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 |
|
357 |
|
358 | async 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 |
|
379 |
|
380 | window.navigator.geolocation = {
|
381 | getCurrentPosition,
|
382 | watchPosition,
|
383 | clearWatch,
|
384 |
|
385 |
|
386 |
|
387 | stopObserving: () => {},
|
388 | };
|
389 |
|
390 | const Location = {
|
391 | getProviderStatusAsync,
|
392 | getCurrentPositionAsync,
|
393 | watchPositionAsync,
|
394 | getHeadingAsync,
|
395 | watchHeadingAsync,
|
396 | geocodeAsync,
|
397 | reverseGeocodeAsync,
|
398 | setApiKey,
|
399 |
|
400 |
|
401 | EventEmitter: LocationEventEmitter,
|
402 | _getCurrentWatchId,
|
403 | };
|
404 |
|
405 | export default Location;
|