xref: /expo/apps/test-suite/tests/Location.js (revision 22d1e005)
1'use strict';
2
3import Constants from 'expo-constants';
4import * as Location from 'expo-location';
5import * as TaskManager from 'expo-task-manager';
6import { Platform } from 'react-native';
7
8import * as TestUtils from '../TestUtils';
9
10const BACKGROUND_LOCATION_TASK = 'background-location-updates';
11const GEOFENCING_TASK = 'geofencing-task';
12
13export const name = 'Location';
14
15export async function test(t) {
16  const shouldSkipTestsRequiringPermissions =
17    await TestUtils.shouldSkipTestsRequiringPermissionsAsync();
18  const describeWithPermissions = shouldSkipTestsRequiringPermissions ? t.xdescribe : t.describe;
19
20  const testShapeOrUnauthorized = (testFunction) => async () => {
21    const providerStatus = await Location.getProviderStatusAsync();
22    if (providerStatus.locationServicesEnabled) {
23      const { status } = await TestUtils.acceptPermissionsAndRunCommandAsync(() => {
24        return Location.requestForegroundPermissionsAsync();
25      });
26      if (status === 'granted') {
27        const location = await testFunction();
28        testLocationShape(location);
29      } else {
30        let error;
31        try {
32          await testFunction();
33        } catch (e) {
34          error = e;
35        }
36        t.expect(error.message).toMatch(/Not authorized/);
37      }
38    } else {
39      let error;
40      try {
41        await testFunction();
42      } catch (e) {
43        error = e;
44      }
45      t.expect(error.message).toMatch(/Location services are disabled/);
46    }
47  };
48
49  function testLocationShape(location) {
50    t.expect(typeof location === 'object').toBe(true);
51
52    const { coords, timestamp } = location;
53    const { latitude, longitude, altitude, accuracy, altitudeAccuracy, heading, speed } = coords;
54
55    t.expect(typeof latitude === 'number').toBe(true);
56    t.expect(typeof longitude === 'number').toBe(true);
57    t.expect(typeof altitude === 'number' || altitude === null).toBe(true);
58    t.expect(typeof accuracy === 'number' || accuracy === null).toBe(true);
59    t.expect(typeof altitudeAccuracy === 'number' || altitudeAccuracy === null).toBe(true);
60    t.expect(typeof heading === 'number' || heading === null).toBe(true);
61    t.expect(typeof speed === 'number' || speed === null).toBe(true);
62    t.expect(typeof timestamp === 'number').toBe(true);
63  }
64
65  t.describe('Location', () => {
66    // On Android, foreground permission needs to be asked before the background permissions
67    describeWithPermissions('Location.requestForegroundPermissionsAsync()', () => {
68      t.it('requests foreground location permissions', async () => {
69        const permission = await Location.requestForegroundPermissionsAsync();
70        t.expect(permission.granted).toBe(true);
71        t.expect(permission.status).toBe(Location.PermissionStatus.GRANTED);
72      });
73    });
74
75    describeWithPermissions('Location.getForegroundPermissionsAsync()', () => {
76      t.it('gets foreground location permissions', async () => {
77        const permission = await Location.getForegroundPermissionsAsync();
78        t.expect(permission.granted).toBe(true);
79        t.expect(permission.status).toBe(Location.PermissionStatus.GRANTED);
80      });
81    });
82
83    describeWithPermissions('Location.requestBackgroundPermissionsAsync()', () => {
84      t.it('requests background location permissions', async () => {
85        const permission = await Location.requestBackgroundPermissionsAsync();
86        t.expect(permission.granted).toBe(true);
87        t.expect(permission.status).toBe(Location.PermissionStatus.GRANTED);
88      });
89    });
90
91    describeWithPermissions('Location.getBackgroundPermissionsAsync()', () => {
92      t.it('gets background location permissions', async () => {
93        const permission = await Location.getBackgroundPermissionsAsync();
94        t.expect(permission.granted).toBe(true);
95        t.expect(permission.status).toBe(Location.PermissionStatus.GRANTED);
96      });
97    });
98
99    t.describe('Location.hasServicesEnabledAsync()', () => {
100      t.it('checks if location services are enabled', async () => {
101        const result = await Location.hasServicesEnabledAsync();
102        t.expect(result).toBe(true);
103      });
104    });
105
106    t.describe('Location.getProviderStatusAsync()', () => {
107      const timeout = 1000;
108      t.it(
109        'checks if location services are enabled',
110        async () => {
111          const result = await Location.getProviderStatusAsync();
112          t.expect(result.locationServicesEnabled).not.toBe(undefined);
113        },
114        timeout
115      );
116      if (Platform.OS === 'android') {
117        t.it(
118          'detects when GPS sensor is enabled',
119          async () => {
120            const result = await Location.getProviderStatusAsync();
121            t.expect(result.gpsAvailable).not.toBe(undefined);
122          },
123          timeout
124        );
125        t.it(
126          'detects when network location is enabled',
127          async () => {
128            const result = await Location.getProviderStatusAsync();
129            t.expect(result.networkAvailable).not.toBe(undefined);
130          },
131          timeout
132        );
133        t.it(
134          'detects when passive location is enabled',
135          async () => {
136            const result = await Location.getProviderStatusAsync();
137            t.expect(result.passiveAvailable).not.toBe(undefined);
138          },
139          timeout
140        );
141      }
142    });
143
144    t.describe('Location.enableNetworkProviderAsync()', () => {
145      // To properly test this, you need to change device's location mode to "Device only" in system settings.
146      // In this mode, network provider is off.
147
148      t.it(
149        'asks user to enable network provider or just resolves on iOS',
150        async () => {
151          try {
152            await Location.enableNetworkProviderAsync();
153
154            if (Platform.OS === 'android') {
155              const result = await Location.getProviderStatusAsync();
156              t.expect(result.networkAvailable).toBe(true);
157            }
158          } catch (error) {
159            // User has denied the dialog.
160            t.expect(error.code).toBe('E_LOCATION_SETTINGS_UNSATISFIED');
161          }
162        },
163        20000
164      );
165    });
166
167    describeWithPermissions('Location.getCurrentPositionAsync()', () => {
168      // Manual interaction:
169      //   1. Just try
170      //   2. iOS Settings --> General --> Reset --> Reset Location & Privacy,
171      //      try gain and "Allow"
172      //   3. Retry from experience restart.
173      //   4. Retry from app restart.
174      //   5. iOS Settings --> General --> Reset --> Reset Location & Privacy,
175      //      try gain and "Don't Allow"
176      //   6. Retry from experience restart.
177      //   7. Retry from app restart.
178      const second = 1000;
179      const timeout = 20 * second; // Allow manual touch on permissions dialog
180
181      t.it(
182        'gets a result of the correct shape (without high accuracy), or ' +
183          'throws error if no permission or disabled',
184        testShapeOrUnauthorized(() =>
185          Location.getCurrentPositionAsync({
186            accuracy: Location.Accuracy.Balanced,
187          })
188        ),
189        timeout
190      );
191      t.it(
192        'gets a result of the correct shape (without high accuracy), or ' +
193          'throws error if no permission or disabled (when trying again immediately)',
194        testShapeOrUnauthorized(() =>
195          Location.getCurrentPositionAsync({
196            accuracy: Location.Accuracy.Balanced,
197          })
198        ),
199        timeout
200      );
201      t.it(
202        'gets a result of the correct shape (with high accuracy), or ' +
203          'throws error if no permission or disabled (when trying again immediately)',
204        testShapeOrUnauthorized(() =>
205          Location.getCurrentPositionAsync({
206            accuracy: Location.Accuracy.Highest,
207          })
208        ),
209        timeout
210      );
211
212      t.it(
213        'gets a result of the correct shape (without high accuracy), or ' +
214          'throws error if no permission or disabled (when trying again after 1 second)',
215        async () => {
216          await new Promise((resolve) => setTimeout(resolve, 1000));
217          await testShapeOrUnauthorized(() =>
218            Location.getCurrentPositionAsync({
219              accuracy: Location.Accuracy.Balanced,
220            })
221          )();
222        },
223        timeout + second
224      );
225
226      t.it(
227        'gets a result of the correct shape (with high accuracy), or ' +
228          'throws error if no permission or disabled (when trying again after 1 second)',
229        async () => {
230          await new Promise((resolve) => setTimeout(resolve, 1000));
231          await testShapeOrUnauthorized(() =>
232            Location.getCurrentPositionAsync({
233              accuracy: Location.Accuracy.Highest,
234            })
235          )();
236        },
237        timeout + second
238      );
239
240      t.it(
241        'resolves when called simultaneously',
242        async () => {
243          await Promise.all([
244            Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Low }),
245            Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Lowest }),
246            Location.getCurrentPositionAsync(),
247          ]);
248        },
249        timeout
250      );
251
252      t.it('resolves when watchPositionAsync is running', async () => {
253        const subscriber = await Location.watchPositionAsync({}, () => {});
254        await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Low });
255        subscriber.remove();
256      });
257    });
258
259    describeWithPermissions('Location.getLastKnownPositionAsync()', () => {
260      const second = 1000;
261      const timeout = 20 * second; // Allow manual touch on permissions dialog
262
263      t.it(
264        'gets a result of the correct shape, or throws error if no permission or disabled',
265        testShapeOrUnauthorized(() => Location.getLastKnownPositionAsync()),
266        timeout
267      );
268
269      t.it(
270        'returns the same or newer location as previously ran `getCurrentPositionAsync`',
271        async () => {
272          const current = await Location.getCurrentPositionAsync({
273            accuracy: Location.Accuracy.Lowest,
274          });
275          const lastKnown = await Location.getLastKnownPositionAsync();
276
277          t.expect(current).not.toBeNull();
278          t.expect(lastKnown).not.toBeNull();
279          t.expect(lastKnown.timestamp).toBeGreaterThanOrEqual(current.timestamp);
280        }
281      );
282
283      t.it('returns null if maxAge is zero', async () => {
284        const location = await Location.getLastKnownPositionAsync({ maxAge: 0 });
285        t.expect(location).toBeNull();
286      });
287
288      t.it('returns null if maxAge is negative', async () => {
289        const location = await Location.getLastKnownPositionAsync({ maxAge: -1000 });
290        t.expect(location).toBeNull();
291      });
292
293      t.it("returns location that doesn't exceed maxAge or null", async () => {
294        const maxAge = 5000;
295        const timestampBeforeCall = Date.now();
296        const location = await Location.getLastKnownPositionAsync({ maxAge });
297
298        if (location !== null) {
299          t.expect(timestampBeforeCall - location.timestamp).toBeLessThan(maxAge);
300        }
301      });
302
303      t.it('returns location with required accuracy or null', async () => {
304        const requiredAccuracy = 70;
305        const location = await Location.getLastKnownPositionAsync({ requiredAccuracy });
306
307        if (location !== null) {
308          t.expect(location.coords.accuracy).toBeLessThanOrEqual(requiredAccuracy);
309        }
310      });
311
312      t.it('returns null if required accuracy is zero', async () => {
313        const location = await Location.getLastKnownPositionAsync({
314          requiredAccuracy: 0,
315        });
316        t.expect(location).toBeNull();
317      });
318
319      t.it(
320        'resolves when called simultaneously',
321        async () => {
322          await Promise.all([
323            Location.getLastKnownPositionAsync(),
324            Location.getLastKnownPositionAsync({ maxAge: 1000 }),
325            Location.getLastKnownPositionAsync({ requiredAccuracy: 100 }),
326          ]);
327        },
328        timeout
329      );
330
331      t.it('resolves when watchPositionAsync is running', async () => {
332        const subscriber = await Location.watchPositionAsync({}, () => {});
333        await Location.getLastKnownPositionAsync();
334        subscriber.remove();
335      });
336    });
337
338    describeWithPermissions('Location.watchPositionAsync()', () => {
339      t.it('gets a result of the correct shape', async () => {
340        await new Promise(async (resolve, reject) => {
341          const subscriber = await Location.watchPositionAsync({}, (location) => {
342            testLocationShape(location);
343            subscriber.remove();
344            resolve();
345          });
346        });
347      });
348
349      t.it('can be called simultaneously', async () => {
350        const spies = [1, 2, 3].map((number) => t.jasmine.createSpy(`watchPosition${number}`));
351
352        const subscribers = await Promise.all(
353          spies.map((spy) => Location.watchPositionAsync({}, spy))
354        );
355
356        await new Promise((resolve) => setTimeout(resolve, 3000));
357
358        spies.forEach((spy) => t.expect(spy).toHaveBeenCalled());
359        subscribers.forEach((subscriber) => subscriber.remove());
360      });
361    });
362
363    if (Platform.OS !== 'web') {
364      describeWithPermissions('Location.getHeadingAsync()', () => {
365        const testCompass = (options) => async () => {
366          // Disable Compass Test if in simulator
367          if (Constants.isDevice) {
368            const { status } = await TestUtils.acceptPermissionsAndRunCommandAsync(() => {
369              return Location.requestForegroundPermissionsAsync();
370            });
371            if (status === 'granted') {
372              const heading = await Location.getHeadingAsync();
373              t.expect(typeof heading.magHeading === 'number').toBe(true);
374              t.expect(typeof heading.trueHeading === 'number').toBe(true);
375              t.expect(typeof heading.accuracy === 'number').toBe(true);
376            } else {
377              let error;
378              try {
379                await Location.getHeadingAsync();
380              } catch (e) {
381                error = e;
382              }
383              t.expect(error.message).toMatch(/Not authorized/);
384            }
385          }
386        };
387        const second = 1000;
388        const timeout = 20 * second; // Allow manual touch on permissions dialog
389
390        t.it(
391          'Checks if compass is returning right values (trueHeading, magHeading, accuracy)',
392          testCompass(),
393          timeout
394        );
395      });
396
397      t.describe('Location.geocodeAsync()', () => {
398        const timeout = 2000;
399
400        t.it(
401          'geocodes a place of the right shape',
402          async () => {
403            const result = await Location.geocodeAsync('900 State St, Salem, OR');
404            t.expect(Array.isArray(result)).toBe(true);
405            t.expect(typeof result[0]).toBe('object');
406            const { latitude, longitude, accuracy, altitude } = result[0];
407            t.expect(typeof latitude).toBe('number');
408            t.expect(typeof longitude).toBe('number');
409            t.expect(typeof accuracy).toBe('number');
410            t.expect(typeof altitude).toBe('number');
411          },
412          timeout
413        );
414
415        t.it(
416          'returns an empty array when the address is not found',
417          async () => {
418            const result = await Location.geocodeAsync(':(');
419            t.expect(result).toEqual([]);
420          },
421          timeout
422        );
423      });
424
425      t.describe('Location.reverseGeocodeAsync()', () => {
426        const timeout = 2000;
427
428        t.it(
429          'gives a right shape address of a point location',
430          async () => {
431            const result = await Location.reverseGeocodeAsync({
432              latitude: 60.166595,
433              longitude: 24.944865,
434            });
435            t.expect(Array.isArray(result)).toBe(true);
436            t.expect(typeof result[0]).toBe('object');
437            const fields = ['city', 'street', 'region', 'country', 'postalCode', 'name'];
438            fields.forEach((field) => {
439              t.expect(
440                typeof result[field] === 'string' || typeof result[field] === 'undefined'
441              ).toBe(true);
442            });
443          },
444          timeout
445        );
446
447        t.it("throws for a location where `latitude` and `longitude` aren't numbers", async () => {
448          let error;
449          try {
450            await Location.reverseGeocodeAsync({
451              latitude: '60',
452              longitude: '24',
453            });
454          } catch (e) {
455            error = e;
456          }
457          t.expect(error instanceof TypeError).toBe(true);
458        });
459      });
460
461      describeWithPermissions('Location - background location updates', () => {
462        async function expectTaskAccuracyToBe(accuracy) {
463          const locationTask = await TaskManager.getTaskOptionsAsync(BACKGROUND_LOCATION_TASK);
464
465          t.expect(locationTask).toBeDefined();
466          t.expect(locationTask.accuracy).toBe(accuracy);
467        }
468
469        t.it('starts location updates', async () => {
470          await Location.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
471        });
472
473        t.it('has started location updates', async () => {
474          const started = await Location.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
475          t.expect(started).toBe(true);
476        });
477
478        t.it('defaults to balanced accuracy', async () => {
479          await expectTaskAccuracyToBe(Location.Accuracy.Balanced);
480        });
481
482        t.it('can update existing task', async () => {
483          const newAccuracy = Location.Accuracy.Highest;
484          await Location.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK, {
485            accuracy: newAccuracy,
486          });
487          expectTaskAccuracyToBe(newAccuracy);
488        });
489
490        t.it('stops location updates', async () => {
491          await Location.stopLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
492        });
493
494        t.it('has stopped location updates', async () => {
495          const started = await Location.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
496          t.expect(started).toBe(false);
497        });
498      });
499
500      describeWithPermissions('Location - geofencing', () => {
501        const regions = [
502          {
503            identifier: 'Kraków, Poland',
504            radius: 8000,
505            latitude: 50.0468548,
506            longitude: 19.9348341,
507            notifyOnEntry: true,
508            notifyOnExit: true,
509          },
510          {
511            identifier: 'Apple',
512            radius: 1000,
513            latitude: 37.3270145,
514            longitude: -122.0310273,
515            notifyOnEntry: true,
516            notifyOnExit: true,
517          },
518        ];
519
520        async function expectTaskRegionsToBeLike(regions) {
521          const geofencingTask = await TaskManager.getTaskOptionsAsync(GEOFENCING_TASK);
522
523          t.expect(geofencingTask).toBeDefined();
524          t.expect(geofencingTask.regions).toBeDefined();
525          t.expect(geofencingTask.regions.length).toBe(regions.length);
526
527          for (let i = 0; i < regions.length; i++) {
528            t.expect(geofencingTask.regions[i].identifier).toBe(regions[i].identifier);
529            t.expect(geofencingTask.regions[i].radius).toBe(regions[i].radius);
530            t.expect(geofencingTask.regions[i].latitude).toBe(regions[i].latitude);
531            t.expect(geofencingTask.regions[i].longitude).toBe(regions[i].longitude);
532          }
533        }
534
535        t.it('starts geofencing', async () => {
536          await Location.startGeofencingAsync(GEOFENCING_TASK, regions);
537        });
538
539        t.it('has started geofencing', async () => {
540          const started = await Location.hasStartedGeofencingAsync(GEOFENCING_TASK);
541          t.expect(started).toBe(true);
542        });
543
544        t.it('is monitoring correct regions', async () => {
545          expectTaskRegionsToBeLike(regions);
546        });
547
548        t.it('can update geofencing regions', async () => {
549          const newRegions = regions.slice(1);
550          await Location.startGeofencingAsync(GEOFENCING_TASK, newRegions);
551          expectTaskRegionsToBeLike(newRegions);
552        });
553
554        t.it('stops geofencing', async () => {
555          await Location.stopGeofencingAsync(GEOFENCING_TASK);
556        });
557
558        t.it('has stopped geofencing', async () => {
559          const started = await Location.hasStartedGeofencingAsync(GEOFENCING_TASK);
560          t.expect(started).toBe(false);
561        });
562
563        t.it('throws when starting geofencing with incorrect regions', async () => {
564          await (async () => {
565            let error;
566            try {
567              await Location.startGeofencingAsync(GEOFENCING_TASK, []);
568            } catch (e) {
569              error = e;
570            }
571            t.expect(error instanceof Error).toBe(true);
572          })();
573
574          await (async () => {
575            let error;
576            try {
577              await Location.startGeofencingAsync(GEOFENCING_TASK, [{ longitude: 'not a number' }]);
578            } catch (e) {
579              error = e;
580            }
581            t.expect(error instanceof TypeError).toBe(true);
582          })();
583        });
584      });
585    }
586  });
587}
588
589// Define empty tasks, otherwise tasks might automatically unregister themselves if no task is defined.
590TaskManager.defineTask(BACKGROUND_LOCATION_TASK, () => {});
591TaskManager.defineTask(GEOFENCING_TASK, () => {});
592