Live location sharing: geolocation utilities (#8126)

* geolocation utilities

Signed-off-by: Kerry Archibald <kerrya@element.io>

* remove debug

Signed-off-by: Kerry Archibald <kerrya@element.io>

* comments for ts-ignores

Signed-off-by: Kerry Archibald <kerrya@element.io>
This commit is contained in:
Kerry 2022-03-23 18:08:56 +01:00 committed by GitHub
parent 3534e9b6ce
commit 1495c23a14
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 358 additions and 95 deletions

View file

@ -36,6 +36,7 @@ import AccessibleButton from '../elements/AccessibleButton';
import { MapError } from './MapError';
import { getUserNameColorClass } from '../../../utils/FormattingUtils';
import LiveDurationDropdown, { DEFAULT_DURATION_MS } from './LiveDurationDropdown';
import { GenericPosition, genericPositionFromGeolocation, getGeoUri } from '../../../utils/beacon';
import SdkConfig from '../../../SdkConfig';
export interface ILocationPickerProps {
sender: RoomMember;
@ -44,16 +45,9 @@ export interface ILocationPickerProps {
onFinished(ev?: SyntheticEvent): void;
}
interface IPosition {
latitude: number;
longitude: number;
altitude?: number;
accuracy?: number;
timestamp: number;
}
interface IState {
timeout: number;
position?: IPosition;
position?: GenericPosition;
error?: LocationShareError;
}
@ -301,32 +295,6 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
}
}
const genericPositionFromGeolocation = (geoPosition: GeolocationPosition): IPosition => {
const {
latitude, longitude, altitude, accuracy,
} = geoPosition.coords;
return {
timestamp: geoPosition.timestamp,
latitude, longitude, altitude, accuracy,
};
};
export function getGeoUri(position: IPosition): string {
const lat = position.latitude;
const lon = position.longitude;
const alt = (
Number.isFinite(position.altitude)
? `,${position.altitude}`
: ""
);
const acc = (
Number.isFinite(position.accuracy)
? `;u=${position.accuracy}`
: ""
);
return `geo:${lat},${lon}${alt}${acc}`;
}
export default LocationPicker;
function positionFailureMessage(code: number): string {

View file

@ -0,0 +1,126 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
// map GeolocationPositionError codes
// https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError
export enum GeolocationError {
// no navigator.geolocation
Unavailable = 'Unavailable',
// The acquisition of the geolocation information failed because the page didn't have the permission to do it.
PermissionDenied = 'PermissionDenied',
// The acquisition of the geolocation failed because at least one internal source of position returned an internal error.
PositionUnavailable = 'PositionUnavailable',
// The time allowed to acquire the geolocation was reached before the information was obtained.
Timeout = 'Timeout',
// other unexpected failure
Default = 'Default'
}
const GeolocationOptions = {
timeout: 5000,
maximumAge: 1000,
};
const isGeolocationPositionError = (error: unknown): error is GeolocationPositionError =>
typeof error === 'object' && !!error['PERMISSION_DENIED'];
/**
* Maps GeolocationPositionError to our GeolocationError enum
*/
export const mapGeolocationError = (error: GeolocationPositionError | Error): GeolocationError => {
logger.error('Geolocation failed', error?.message ?? error);
if (isGeolocationPositionError(error)) {
switch (error?.code) {
case error.PERMISSION_DENIED:
return GeolocationError.PermissionDenied;
case error.POSITION_UNAVAILABLE:
return GeolocationError.PositionUnavailable;
case error.TIMEOUT:
return GeolocationError.Timeout;
default:
return GeolocationError.Default;
}
} else if (error.message === GeolocationError.Unavailable) {
return GeolocationError.Unavailable;
} else {
return GeolocationError.Default;
}
};
const getGeolocation = (): Geolocation => {
if (!navigator.geolocation) {
throw new Error(GeolocationError.Unavailable);
}
return navigator.geolocation;
};
export type GenericPosition = {
latitude: number;
longitude: number;
altitude?: number;
accuracy?: number;
timestamp: number;
};
export type TimedGeoUri = {
geoUri: string;
timestamp: number;
};
export const genericPositionFromGeolocation = (geoPosition: GeolocationPosition): GenericPosition => {
const {
latitude, longitude, altitude, accuracy,
} = geoPosition.coords;
return {
timestamp: geoPosition.timestamp,
latitude, longitude, altitude, accuracy,
};
};
export const getGeoUri = (position: GenericPosition): string => {
const lat = position.latitude;
const lon = position.longitude;
const alt = (
Number.isFinite(position.altitude)
? `,${position.altitude}`
: ""
);
const acc = (
Number.isFinite(position.accuracy)
? `;u=${position.accuracy}`
: ""
);
return `geo:${lat},${lon}${alt}${acc}`;
};
export const mapGeolocationPositionToTimedGeo = (position: GeolocationPosition): TimedGeoUri => {
return { timestamp: position.timestamp, geoUri: getGeoUri(genericPositionFromGeolocation(position)) };
};
export const watchPosition = (
onWatchPosition: PositionCallback,
onWatchPositionError: (error: GeolocationError) => void): () => void => {
try {
const onError = (error) => onWatchPositionError(mapGeolocationError(error));
const watchId = getGeolocation().watchPosition(onWatchPosition, onError, GeolocationOptions);
const clearWatch = () => getGeolocation().clearWatch(watchId);
return clearWatch;
} catch (error) {
throw new Error(mapGeolocationError(error));
}
};

View file

@ -15,3 +15,4 @@ limitations under the License.
*/
export * from './duration';
export * from './geolocation';

View file

@ -24,7 +24,7 @@ import { mocked } from 'jest-mock';
import { logger } from 'matrix-js-sdk/src/logger';
import "../../../skinned-sdk"; // Must be first for skinning to work
import LocationPicker, { getGeoUri } from "../../../../src/components/views/location/LocationPicker";
import LocationPicker from "../../../../src/components/views/location/LocationPicker";
import { LocationShareType } from "../../../../src/components/views/location/shareLocation";
import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
import { MatrixClientPeg } from '../../../../src/MatrixClientPeg';
@ -40,65 +40,6 @@ jest.mock('../../../../src/components/views/location/findMapStyleUrl', () => ({
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
describe("LocationPicker", () => {
describe("getGeoUri", () => {
it("Renders a URI with only lat and lon", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: undefined,
accuracy: undefined,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4");
});
it("Nulls in location are not shown in URI", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: null,
accuracy: null,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4");
});
it("Renders a URI with 3 coords", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: 332.54,
accuracy: undefined,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,332.54");
});
it("Renders a URI with accuracy", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: undefined,
accuracy: 21,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4;u=21");
});
it("Renders a URI with accuracy and altitude", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: 12.3,
accuracy: 21,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,12.3;u=21");
});
});
describe('<LocationPicker />', () => {
const roomId = '!room:server.org';
const userId = '@user:server.org';

View file

@ -18,6 +18,7 @@ import { makeBeaconInfoContent, makeBeaconContent } from "matrix-js-sdk/src/cont
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { M_BEACON, M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon";
import { LocationAssetType } from "matrix-js-sdk/src/@types/location";
import { MockedObject } from "jest-mock";
type InfoContentProps = {
timeout: number;
@ -107,7 +108,7 @@ export const makeBeaconEvent = (
*/
export const makeGeolocationPosition = (
{ timestamp, coords }:
{ timestamp?: number, coords: Partial<GeolocationCoordinates> },
{ timestamp?: number, coords?: Partial<GeolocationCoordinates> },
): GeolocationPosition => ({
timestamp: timestamp ?? 1647256791840,
coords: {
@ -121,3 +122,22 @@ export const makeGeolocationPosition = (
...coords,
},
});
/**
* Creates a basic mock of Geolocation
* sets navigator.geolocation to the mock
* and returns mock
*/
export const mockGeolocation = (): MockedObject<Geolocation> => {
const mockGeolocation = {
clearWatch: jest.fn(),
getCurrentPosition: jest.fn().mockImplementation(callback => callback(makeGeolocationPosition({}))),
watchPosition: jest.fn().mockImplementation(callback => callback(makeGeolocationPosition({}))),
} as unknown as MockedObject<Geolocation>;
// jest jsdom does not provide geolocation
// @ts-ignore illegal assignment to readonly property
navigator.geolocation = mockGeolocation;
return mockGeolocation;
};

View file

@ -0,0 +1,207 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
import {
GeolocationError,
getGeoUri,
mapGeolocationError,
mapGeolocationPositionToTimedGeo,
watchPosition,
} from "../../../src/utils/beacon";
import { makeGeolocationPosition, mockGeolocation } from "../../test-utils/beacon";
describe('geolocation utilities', () => {
let geolocation;
const defaultPosition = makeGeolocationPosition({});
// https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError
const getMockGeolocationPositionError = (code, message) => ({
code, message,
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3,
});
beforeEach(() => {
geolocation = mockGeolocation();
});
afterEach(() => {
jest.spyOn(logger, 'error').mockRestore();
});
describe('getGeoUri', () => {
it("Renders a URI with only lat and lon", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: undefined,
accuracy: undefined,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4");
});
it("Nulls in location are not shown in URI", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: null,
accuracy: null,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4");
});
it("Renders a URI with 3 coords", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: 332.54,
accuracy: undefined,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,332.54");
});
it("Renders a URI with accuracy", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: undefined,
accuracy: 21,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4;u=21");
});
it("Renders a URI with accuracy and altitude", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: 12.3,
accuracy: 21,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,12.3;u=21");
});
});
describe('mapGeolocationError', () => {
beforeEach(() => {
// suppress expected errors from test log
jest.spyOn(logger, 'error').mockImplementation(() => { });
});
it('returns default for other error', () => {
const error = new Error('oh no..');
expect(mapGeolocationError(error)).toEqual(GeolocationError.Default);
});
it('returns unavailable for unavailable error', () => {
const error = new Error(GeolocationError.Unavailable);
expect(mapGeolocationError(error)).toEqual(GeolocationError.Unavailable);
});
it('maps geo error permissiondenied correctly', () => {
const error = getMockGeolocationPositionError(1, 'message');
expect(mapGeolocationError(error)).toEqual(GeolocationError.PermissionDenied);
});
it('maps geo position unavailable error correctly', () => {
const error = getMockGeolocationPositionError(2, 'message');
expect(mapGeolocationError(error)).toEqual(GeolocationError.PositionUnavailable);
});
it('maps geo timeout error correctly', () => {
const error = getMockGeolocationPositionError(3, 'message');
expect(mapGeolocationError(error)).toEqual(GeolocationError.Timeout);
});
});
describe('mapGeolocationPositionToTimedGeo()', () => {
it('maps geolocation position correctly', () => {
expect(mapGeolocationPositionToTimedGeo(defaultPosition)).toEqual({
timestamp: 1647256791840, geoUri: 'geo:54.001927,-8.253491;u=1',
});
});
});
describe('watchPosition()', () => {
it('throws with unavailable error when geolocation is not available', () => {
// suppress expected errors from test log
jest.spyOn(logger, 'error').mockImplementation(() => { });
// remove the mock we added
// @ts-ignore illegal assignment to readonly property
navigator.geolocation = undefined;
const positionHandler = jest.fn();
const errorHandler = jest.fn();
expect(() => watchPosition(positionHandler, errorHandler)).toThrow(GeolocationError.Unavailable);
});
it('sets up position handler with correct options', () => {
const positionHandler = jest.fn();
const errorHandler = jest.fn();
watchPosition(positionHandler, errorHandler);
const [, , options] = geolocation.watchPosition.mock.calls[0];
expect(options).toEqual({
maximumAge: 1000,
timeout: 5000,
});
});
it('returns clearWatch function', () => {
const watchId = 1;
geolocation.watchPosition.mockReturnValue(watchId);
const positionHandler = jest.fn();
const errorHandler = jest.fn();
const clearWatch = watchPosition(positionHandler, errorHandler);
clearWatch();
expect(geolocation.clearWatch).toHaveBeenCalledWith(watchId);
});
it('calls position handler with position', () => {
const positionHandler = jest.fn();
const errorHandler = jest.fn();
watchPosition(positionHandler, errorHandler);
expect(positionHandler).toHaveBeenCalledWith(defaultPosition);
});
it('maps geolocation position error and calls error handler', () => {
// suppress expected errors from test log
jest.spyOn(logger, 'error').mockImplementation(() => { });
geolocation.watchPosition.mockImplementation(
(_callback, error) => error(getMockGeolocationPositionError(1, 'message')),
);
const positionHandler = jest.fn();
const errorHandler = jest.fn();
watchPosition(positionHandler, errorHandler);
expect(errorHandler).toHaveBeenCalledWith(GeolocationError.PermissionDenied);
});
});
});