From 1495c23a14aa515ed492c722f9c54a3afa0f2870 Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 23 Mar 2022 18:08:56 +0100 Subject: [PATCH] Live location sharing: geolocation utilities (#8126) * geolocation utilities Signed-off-by: Kerry Archibald * remove debug Signed-off-by: Kerry Archibald * comments for ts-ignores Signed-off-by: Kerry Archibald --- .../views/location/LocationPicker.tsx | 36 +-- src/utils/beacon/geolocation.ts | 126 +++++++++++ src/utils/beacon/index.ts | 1 + .../views/location/LocationPicker-test.tsx | 61 +----- test/test-utils/beacon.ts | 22 +- test/utils/beacon/geolocation-test.ts | 207 ++++++++++++++++++ 6 files changed, 358 insertions(+), 95 deletions(-) create mode 100644 src/utils/beacon/geolocation.ts create mode 100644 test/utils/beacon/geolocation-test.ts diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index 39455b6476..b7d51df191 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -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 { } } -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 { diff --git a/src/utils/beacon/geolocation.ts b/src/utils/beacon/geolocation.ts new file mode 100644 index 0000000000..3c0d13251e --- /dev/null +++ b/src/utils/beacon/geolocation.ts @@ -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)); + } +}; diff --git a/src/utils/beacon/index.ts b/src/utils/beacon/index.ts index 7f922bed10..1308e03878 100644 --- a/src/utils/beacon/index.ts +++ b/src/utils/beacon/index.ts @@ -15,3 +15,4 @@ limitations under the License. */ export * from './duration'; +export * from './geolocation'; diff --git a/test/components/views/location/LocationPicker-test.tsx b/test/components/views/location/LocationPicker-test.tsx index 914821a53d..b3d29e7089 100644 --- a/test/components/views/location/LocationPicker-test.tsx +++ b/test/components/views/location/LocationPicker-test.tsx @@ -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('', () => { const roomId = '!room:server.org'; const userId = '@user:server.org'; diff --git a/test/test-utils/beacon.ts b/test/test-utils/beacon.ts index d156baaa5e..fa6bcebe11 100644 --- a/test/test-utils/beacon.ts +++ b/test/test-utils/beacon.ts @@ -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 }, + { timestamp?: number, coords?: Partial }, ): 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 => { + const mockGeolocation = { + clearWatch: jest.fn(), + getCurrentPosition: jest.fn().mockImplementation(callback => callback(makeGeolocationPosition({}))), + watchPosition: jest.fn().mockImplementation(callback => callback(makeGeolocationPosition({}))), + } as unknown as MockedObject; + + // jest jsdom does not provide geolocation + // @ts-ignore illegal assignment to readonly property + navigator.geolocation = mockGeolocation; + + return mockGeolocation; +}; diff --git a/test/utils/beacon/geolocation-test.ts b/test/utils/beacon/geolocation-test.ts new file mode 100644 index 0000000000..51aabd3600 --- /dev/null +++ b/test/utils/beacon/geolocation-test.ts @@ -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); + }); + }); +});