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<ProviderStatus> {
return ExpoLocation.getProviderStatusAsync();
}
export async function enableNetworkProviderAsync(): Promise<void> {
// 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<LocationData> {
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<HeadingData> {
return new Promise<HeadingData>(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<Array<GeocodedLocation>> {
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<Address[]> {
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<GeocodedLocation[]> {
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<Address[]> {
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<any> {
try {
await ExpoLocation.requestPermissionsAsync();
const result = await getCurrentPositionAsync(options);
success(result);
} catch (e) {
error(e);
}
}
export async function requestPermissionsAsync(): Promise<void> {
await ExpoLocation.requestPermissionsAsync();
}
// --- Location service
export async function hasServicesEnabledAsync(): Promise<boolean> {
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<boolean> {
const providerStatus = await getProviderStatusAsync();
return providerStatus.backgroundModeEnabled;
}
export async function startLocationUpdatesAsync(
taskName: string,
options: LocationTaskOptions = { accuracy: LocationAccuracy.Balanced }
): Promise<void> {
_validateTaskName(taskName);
await ExpoLocation.startLocationUpdatesAsync(taskName, options);
}
export async function stopLocationUpdatesAsync(taskName: string): Promise<void> {
_validateTaskName(taskName);
await ExpoLocation.stopLocationUpdatesAsync(taskName);
}
export async function hasStartedLocationUpdatesAsync(taskName: string): Promise<boolean> {
_validateTaskName(taskName);
return ExpoLocation.hasStartedLocationUpdatesAsync(taskName);
}
// --- Geofencing
function _validateRegions(regions: Array<Region>) {
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<Region> = []
): Promise<void> {
_validateTaskName(taskName);
_validateRegions(regions);
await ExpoLocation.startGeofencingAsync(taskName, { regions });
}
export async function stopGeofencingAsync(taskName: string): Promise<void> {
_validateTaskName(taskName);
await ExpoLocation.stopGeofencingAsync(taskName);
}
export async function hasStartedGeofencingAsync(taskName: string): Promise<boolean> {
_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,
};