import { EventEmitter, Platform, CodedError } from '@unimodules/core'; import invariant from 'invariant'; import ExpoLocation from './ExpoLocation'; const LocationEventEmitter = new EventEmitter(ExpoLocation); export interface ProviderStatus { locationServicesEnabled: boolean; backgroundModeEnabled: boolean; gpsAvailable?: boolean; networkAvailable?: boolean; passiveAvailable?: boolean; } export interface LocationOptions { accuracy?: LocationAccuracy; enableHighAccuracy?: boolean; timeInterval?: number; distanceInterval?: number; timeout?: number; mayShowUserSettingsDialog?: boolean; } export interface LocationData { coords: { latitude: number; longitude: number; altitude: number; accuracy: number; heading: number; speed: number; }; timestamp: number; } export interface HeadingData { trueHeading: number; magHeading: number; accuracy: number; } export interface GeocodedLocation { latitude: number; longitude: number; altitude?: number; accuracy?: number; } export interface Address { city: string; street: string; region: string; country: string; postalCode: string; name: string; } interface LocationTaskOptions { accuracy?: LocationAccuracy; timeInterval?: number; // Android only distanceInterval?: number; showsBackgroundLocationIndicator?: boolean; // iOS only deferredUpdatesDistance?: number; deferredUpdatesTimeout?: number; deferredUpdatesInterval?: number; // iOS only activityType?: LocationActivityType; pausesUpdatesAutomatically?: boolean; foregroundService?: { notificationTitle: string; notificationBody: string; notificationColor?: string; }; } interface Region { identifier?: string; latitude: number; longitude: number; radius: number; notifyOnEnter?: boolean; notifyOnExit?: boolean; } type Subscription = { remove: () => void; }; type LocationCallback = (data: LocationData) => any; type HeadingCallback = (data: HeadingData) => any; enum LocationAccuracy { Lowest = 1, Low = 2, Balanced = 3, High = 4, Highest = 5, BestForNavigation = 6, } enum LocationActivityType { Other = 1, AutomotiveNavigation = 2, Fitness = 3, OtherNavigation = 4, Airborne = 5, } export { LocationAccuracy as Accuracy, LocationActivityType as ActivityType }; export enum GeofencingEventType { Enter = 1, Exit = 2, } export enum GeofencingRegionState { Unknown = 0, Inside = 1, Outside = 2, } let nextWatchId = 0; let headingId; function _getNextWatchId() { nextWatchId++; return nextWatchId; } function _getCurrentWatchId() { return nextWatchId; } let watchCallbacks: { [watchId: number]: LocationCallback | HeadingCallback; } = {}; let deviceEventSubscription: Subscription | null; let headingEventSub: Subscription | null; let googleApiKey; const googleApiUrl = 'https://maps.googleapis.com/maps/api/geocode/json'; export async function getProviderStatusAsync(): Promise { return ExpoLocation.getProviderStatusAsync(); } export async function enableNetworkProviderAsync(): Promise { // If network provider is disabled (user's location mode is set to "Device only"), // Android's location provider may not give you any results. Use this method in order to ask the user // to change the location mode to "High accuracy" which uses Google Play services and enables network provider. // `getCurrentPositionAsync` and `watchPositionAsync` are doing it automatically anyway. if (Platform.OS === 'android') { return ExpoLocation.enableNetworkProviderAsync(); } } export async function getCurrentPositionAsync( options: LocationOptions = {} ): Promise { return ExpoLocation.getCurrentPositionAsync(options); } // Start Compass Module // To simplify, we will call watchHeadingAsync and wait for one update To ensure accuracy, we wait // for a couple of watch updates if the data has low accuracy export async function getHeadingAsync(): Promise { return new Promise(async (resolve, reject) => { try { // If there is already a compass active (would be a watch) if (headingEventSub) { let tries = 0; const headingSub = LocationEventEmitter.addListener( 'Expo.headingChanged', ({ heading }: { heading: HeadingData }) => { if (heading.accuracy > 1 || tries > 5) { resolve(heading); LocationEventEmitter.removeSubscription(headingSub); } else { tries += 1; } } ); } else { let done = false; let subscription; let tries = 0; subscription = await watchHeadingAsync((heading: HeadingData) => { if (!done) { if (heading.accuracy > 1 || tries > 5) { subscription.remove(); resolve(heading); done = true; } else { tries += 1; } } else { subscription.remove(); } }); if (done) { subscription.remove(); } } } catch (e) { reject(e); } }); } export async function watchHeadingAsync( callback: HeadingCallback ): Promise<{ remove: () => void }> { // Check if there is already a compass event watch. if (headingEventSub) { _removeHeadingWatcher(headingId); } headingEventSub = LocationEventEmitter.addListener( 'Expo.headingChanged', ({ watchId, heading }: { watchId: string; heading: HeadingData }) => { const callback = watchCallbacks[watchId]; if (callback) { callback(heading); } else { ExpoLocation.removeWatchAsync(watchId); } } ); headingId = _getNextWatchId(); watchCallbacks[headingId] = callback; await ExpoLocation.watchDeviceHeading(headingId); return { remove() { _removeHeadingWatcher(headingId); }, }; } // Removes the compass listener and sub from JS and Native function _removeHeadingWatcher(watchId) { if (!watchCallbacks[watchId]) { return; } delete watchCallbacks[watchId]; ExpoLocation.removeWatchAsync(watchId); if (headingEventSub) { LocationEventEmitter.removeSubscription(headingEventSub); headingEventSub = null; } } // End Compass Module function _maybeInitializeEmitterSubscription() { if (!deviceEventSubscription) { deviceEventSubscription = LocationEventEmitter.addListener( 'Expo.locationChanged', ({ watchId, location }: { watchId: string; location: LocationData }) => { const callback = watchCallbacks[watchId]; if (callback) { callback(location); } else { ExpoLocation.removeWatchAsync(watchId); } } ); } } export async function geocodeAsync(address: string): Promise> { return ExpoLocation.geocodeAsync(address).catch(error => { const platformUsesGoogleMaps = Platform.OS === 'android' || Platform.OS === 'web'; if (platformUsesGoogleMaps && error.code === 'E_NO_GEOCODER') { if (!googleApiKey) { throw new CodedError( error.code, `${error.message} Please set a Google API Key to use geocoding.` ); } return _googleGeocodeAsync(address); } throw error; }); } export async function reverseGeocodeAsync(location: { latitude: number; longitude: number; }): Promise { if (typeof location.latitude !== 'number' || typeof location.longitude !== 'number') { throw new TypeError( 'Location should be an object with number properties `latitude` and `longitude`.' ); } return ExpoLocation.reverseGeocodeAsync(location).catch(error => { const platformUsesGoogleMaps = Platform.OS === 'android' || Platform.OS === 'web'; if (platformUsesGoogleMaps && error.code === 'E_NO_GEOCODER') { if (!googleApiKey) { throw new CodedError( error.code, `${error.message} Please set a Google API Key to use geocoding.` ); } return _googleReverseGeocodeAsync(location); } throw error; }); } export function setApiKey(apiKey: string) { googleApiKey = apiKey; } async function _googleGeocodeAsync(address: string): Promise { const result = await fetch(`${googleApiUrl}?key=${googleApiKey}&address=${encodeURI(address)}`); const resultObject = await result.json(); if (resultObject.status === 'ZERO_RESULTS') { return []; } assertGeocodeResults(resultObject); return resultObject.results.map(result => { let location = result.geometry.location; // TODO: This is missing a lot of props return { latitude: location.lat, longitude: location.lng, }; }); } async function _googleReverseGeocodeAsync(options: { latitude: number; longitude: number; }): Promise { const result = await fetch( `${googleApiUrl}?key=${googleApiKey}&latlng=${options.latitude},${options.longitude}` ); const resultObject = await result.json(); if (resultObject.status === 'ZERO_RESULTS') { return []; } assertGeocodeResults(resultObject); return resultObject.results.map(result => { const address: any = {}; result.address_components.forEach(component => { if (component.types.includes('locality')) { address.city = component.long_name; } else if (component.types.includes('street_address')) { address.street = component.long_name; } else if (component.types.includes('administrative_area_level_1')) { address.region = component.long_name; } else if (component.types.includes('country')) { address.country = component.long_name; } else if (component.types.includes('postal_code')) { address.postalCode = component.long_name; } else if (component.types.includes('point_of_interest')) { address.name = component.long_name; } }); return address as Address; }); } // https://developers.google.com/maps/documentation/geocoding/intro function assertGeocodeResults(resultObject: any): void { const { status, error_message } = resultObject; if (status !== 'ZERO_RESULTS' && status !== 'OK') { if (error_message) { throw new CodedError(status, error_message); } else if (status === 'UNKNOWN_ERROR') { throw new CodedError( status, 'the request could not be processed due to a server error. The request may succeed if you try again.' ); } throw new CodedError(status, `An error occurred during geocoding.`); } } // Polyfill: navigator.geolocation.watchPosition function watchPosition( success: GeoSuccessCallback, error: GeoErrorCallback, options: LocationOptions ) { _maybeInitializeEmitterSubscription(); const watchId = _getNextWatchId(); watchCallbacks[watchId] = success; ExpoLocation.watchPositionImplAsync(watchId, options).catch(err => { _removeWatcher(watchId); error({ watchId, message: err.message, code: err.code }); }); return watchId; } export async function watchPositionAsync(options: LocationOptions, callback: LocationCallback) { _maybeInitializeEmitterSubscription(); const watchId = _getNextWatchId(); watchCallbacks[watchId] = callback; await ExpoLocation.watchPositionImplAsync(watchId, options); return { remove() { _removeWatcher(watchId); }, }; } // Polyfill: navigator.geolocation.clearWatch function clearWatch(watchId: number) { _removeWatcher(watchId); } function _removeWatcher(watchId) { // Do nothing if we have already removed the subscription if (!watchCallbacks[watchId]) { return; } ExpoLocation.removeWatchAsync(watchId); delete watchCallbacks[watchId]; if (Object.keys(watchCallbacks).length === 0 && deviceEventSubscription) { LocationEventEmitter.removeSubscription(deviceEventSubscription); deviceEventSubscription = null; } } type GeoSuccessCallback = (data: LocationData) => void; type GeoErrorCallback = (error: any) => void; function getCurrentPosition( success: GeoSuccessCallback, error: GeoErrorCallback = () => {}, options: LocationOptions = {} ): void { invariant(typeof success === 'function', 'Must provide a valid success callback.'); invariant(typeof options === 'object', 'options must be an object.'); _getCurrentPositionAsyncWrapper(success, error, options); } // This function exists to let us continue to return undefined from getCurrentPosition, while still // using async/await for the internal implementation of it async function _getCurrentPositionAsyncWrapper( success: GeoSuccessCallback, error: GeoErrorCallback, options: LocationOptions ): Promise { try { await ExpoLocation.requestPermissionsAsync(); const result = await getCurrentPositionAsync(options); success(result); } catch (e) { error(e); } } export async function requestPermissionsAsync(): Promise { await ExpoLocation.requestPermissionsAsync(); } // --- Location service export async function hasServicesEnabledAsync(): Promise { return await ExpoLocation.hasServicesEnabledAsync(); } // --- Background location updates function _validateTaskName(taskName: string) { invariant(taskName && typeof taskName === 'string', '`taskName` must be a non-empty string.'); } export async function isBackgroundLocationAvailableAsync(): Promise { const providerStatus = await getProviderStatusAsync(); return providerStatus.backgroundModeEnabled; } export async function startLocationUpdatesAsync( taskName: string, options: LocationTaskOptions = { accuracy: LocationAccuracy.Balanced } ): Promise { _validateTaskName(taskName); await ExpoLocation.startLocationUpdatesAsync(taskName, options); } export async function stopLocationUpdatesAsync(taskName: string): Promise { _validateTaskName(taskName); await ExpoLocation.stopLocationUpdatesAsync(taskName); } export async function hasStartedLocationUpdatesAsync(taskName: string): Promise { _validateTaskName(taskName); return ExpoLocation.hasStartedLocationUpdatesAsync(taskName); } // --- Geofencing function _validateRegions(regions: Array) { if (!regions || regions.length === 0) { throw new Error( 'Regions array cannot be empty. Use `stopGeofencingAsync` if you want to stop geofencing all regions' ); } for (const region of regions) { if (typeof region.latitude !== 'number') { throw new TypeError(`Region's latitude must be a number. Got '${region.latitude}' instead.`); } if (typeof region.longitude !== 'number') { throw new TypeError( `Region's longitude must be a number. Got '${region.longitude}' instead.` ); } if (typeof region.radius !== 'number') { throw new TypeError(`Region's radius must be a number. Got '${region.radius}' instead.`); } } } export async function startGeofencingAsync( taskName: string, regions: Array = [] ): Promise { _validateTaskName(taskName); _validateRegions(regions); await ExpoLocation.startGeofencingAsync(taskName, { regions }); } export async function stopGeofencingAsync(taskName: string): Promise { _validateTaskName(taskName); await ExpoLocation.stopGeofencingAsync(taskName); } export async function hasStartedGeofencingAsync(taskName: string): Promise { _validateTaskName(taskName); return ExpoLocation.hasStartedGeofencingAsync(taskName); } export function installWebGeolocationPolyfill(): void { if (Platform.OS !== 'web') { // Polyfill navigator.geolocation for interop with the core react-native and web API approach to // geolocation // @ts-ignore window.navigator.geolocation = { getCurrentPosition, watchPosition, clearWatch, // We don't polyfill stopObserving, this is an internal method that probably should not even exist // in react-native docs stopObserving: () => {}, }; } } export { // For internal purposes LocationEventEmitter as EventEmitter, _getCurrentWatchId, };