Live location sharing - handle geolocation errors (#8179)

* display live share warning only when geolocation is happening

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

* kill beacons when geolocation is unavailable or permissions denied

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

* polish and comments

Signed-off-by: Kerry Archibald <kerrya@element.io>
This commit is contained in:
Kerry 2022-03-28 18:46:39 +02:00 committed by GitHub
parent 2520d81784
commit d2b97e251e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 287 additions and 60 deletions

View file

@ -27,13 +27,13 @@ interface Props {
}
const LeftPanelLiveShareWarning: React.FC<Props> = ({ isMinimized }) => {
const hasLiveBeacons = useEventEmitterState(
const isMonitoringLiveLocation = useEventEmitterState(
OwnBeaconStore.instance,
OwnBeaconStoreEvent.LivenessChange,
() => OwnBeaconStore.instance.hasLiveBeacons(),
OwnBeaconStoreEvent.MonitoringLivePosition,
() => OwnBeaconStore.instance.isMonitoringLiveLocation,
);
if (!hasLiveBeacons) {
if (!isMonitoringLiveLocation) {
return null;
}

View file

@ -77,6 +77,13 @@ type LiveBeaconsState = {
const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => {
const [stoppingInProgress, setStoppingInProgress] = useState(false);
// do we have an active geolocation.watchPosition
const isMonitoringLiveLocation = useEventEmitterState(
OwnBeaconStore.instance,
OwnBeaconStoreEvent.MonitoringLivePosition,
() => OwnBeaconStore.instance.isMonitoringLiveLocation,
);
const liveBeaconIds = useEventEmitterState(
OwnBeaconStore.instance,
OwnBeaconStoreEvent.LivenessChange,
@ -88,7 +95,7 @@ const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => {
setStoppingInProgress(false);
}, [liveBeaconIds]);
if (!liveBeaconIds?.length) {
if (!isMonitoringLiveLocation || !liveBeaconIds?.length) {
return {};
}

View file

@ -44,6 +44,7 @@ const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconIn
export enum OwnBeaconStoreEvent {
LivenessChange = 'OwnBeaconStore.LivenessChange',
MonitoringLivePosition = 'OwnBeaconStore.MonitoringLivePosition',
}
const MOVING_UPDATE_INTERVAL = 2000;
@ -232,18 +233,28 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent);
};
private togglePollingLocation = async (): Promise<void> => {
private togglePollingLocation = () => {
if (!!this.liveBeaconIds.length) {
return this.startPollingLocation();
this.startPollingLocation();
} else {
this.stopPollingLocation();
}
return this.stopPollingLocation();
};
private startPollingLocation = async () => {
// clear any existing interval
this.stopPollingLocation();
this.clearPositionWatch = await watchPosition(this.onWatchedPosition, this.onWatchedPositionError);
try {
this.clearPositionWatch = await watchPosition(
this.onWatchedPosition,
this.onGeolocationError,
);
} catch (error) {
this.onGeolocationError(error?.message);
// don't set locationInterval if geolocation failed to setup
return;
}
this.locationInterval = setInterval(() => {
if (!this.lastPublishedPositionTimestamp) {
@ -255,6 +266,8 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
this.publishCurrentLocationToBeacons();
}
}, STATIC_UPDATE_INTERVAL);
this.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
};
private onWatchedPosition = (position: GeolocationPosition) => {
@ -268,11 +281,6 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
}
};
private onWatchedPositionError = (error: GeolocationError) => {
this.geolocationError = error;
logger.error(this.geolocationError);
};
private stopPollingLocation = () => {
clearInterval(this.locationInterval);
this.locationInterval = undefined;
@ -283,6 +291,8 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
this.clearPositionWatch();
this.clearPositionWatch = undefined;
}
this.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
};
/**
@ -313,8 +323,31 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
* and publishes it to all live beacons
*/
private publishCurrentLocationToBeacons = async () => {
try {
const position = await getCurrentPosition();
// TODO error handling
this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position));
} catch (error) {
this.onGeolocationError(error?.message);
}
};
private onGeolocationError = async (error: GeolocationError): Promise<void> => {
this.geolocationError = error;
logger.error('Geolocation failed', this.geolocationError);
// other errors are considered non-fatal
// and self recovering
if (![
GeolocationError.Unavailable,
GeolocationError.PermissionDenied,
].includes(error)) {
return;
}
this.stopPollingLocation();
// kill live beacons when location permissions are revoked
// TODO may need adjustment when PSF-797 is done
await Promise.all(this.liveBeaconIds.map(this.stopBeacon));
};
}

View file

@ -49,9 +49,9 @@ describe('<LeftPanelLiveShareWarning />', () => {
expect(component.html()).toBe(null);
});
describe('when user has live beacons', () => {
describe('when user has live location monitor', () => {
beforeEach(() => {
mocked(OwnBeaconStore.instance).hasLiveBeacons.mockReturnValue(true);
mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = true;
});
it('renders correctly when not minimized', () => {
const component = getComponent();
@ -68,8 +68,8 @@ describe('<LeftPanelLiveShareWarning />', () => {
// started out rendered
expect(component.html()).toBeTruthy();
mocked(OwnBeaconStore.instance).hasLiveBeacons.mockReturnValue(false);
OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.LivenessChange, false);
mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false;
OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
await flushPromises();
component.setProps({});

View file

@ -18,10 +18,11 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
import { Room, Beacon, BeaconEvent } from 'matrix-js-sdk/src/matrix';
import { logger } from 'matrix-js-sdk/src/logger';
import '../../../skinned-sdk';
import RoomLiveShareWarning from '../../../../src/components/views/beacon/RoomLiveShareWarning';
import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore';
import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../../src/stores/OwnBeaconStore';
import {
advanceDateAndTime,
findByTestId,
@ -33,7 +34,6 @@ import {
} from '../../../test-utils';
jest.useFakeTimers();
mockGeolocation();
describe('<RoomLiveShareWarning />', () => {
const aliceId = '@alice:server.org';
const room1Id = '$room1:server.org';
@ -94,6 +94,7 @@ describe('<RoomLiveShareWarning />', () => {
};
beforeEach(() => {
mockGeolocation();
jest.spyOn(global.Date, 'now').mockReturnValue(now);
mockClient.unstable_setLiveBeacon.mockClear();
});
@ -123,7 +124,22 @@ describe('<RoomLiveShareWarning />', () => {
expect(component.html()).toBe(null);
});
describe('when user has live beacons', () => {
it('does not render when geolocation is not working', async () => {
jest.spyOn(logger, 'error').mockImplementation(() => { });
// @ts-ignore
navigator.geolocation = undefined;
await act(async () => {
await makeRoomsWithStateEvents([room1Beacon1, room2Beacon1, room2Beacon2]);
await makeOwnBeaconStore();
});
const component = getComponent({ roomId: room1Id });
// beacons have generated ids that break snapshots
// assert on html
expect(component.html()).toBeNull();
});
describe('when user has live beacons and geolocation is available', () => {
beforeEach(async () => {
await act(async () => {
await makeRoomsWithStateEvents([room1Beacon1, room2Beacon1, room2Beacon2]);
@ -164,6 +180,22 @@ describe('<RoomLiveShareWarning />', () => {
expect(component.html()).toBe(null);
});
it('removes itself when user stops monitoring live position', async () => {
const component = getComponent({ roomId: room1Id });
// started out rendered
expect(component.html()).toBeTruthy();
act(() => {
// cheat to clear this
// @ts-ignore
OwnBeaconStore.instance.clearPositionWatch = undefined;
OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
component.setProps({});
});
expect(component.html()).toBe(null);
});
it('renders when user adds a live beacon', async () => {
const component = getComponent({ roomId: room3Id });
// started out not rendered

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LeftPanelLiveShareWarning /> when user has live beacons renders correctly when minimized 1`] = `
exports[`<LeftPanelLiveShareWarning /> when user has live location monitor renders correctly when minimized 1`] = `
<LeftPanelLiveShareWarning
isMinimized={true}
>
@ -15,7 +15,7 @@ exports[`<LeftPanelLiveShareWarning /> when user has live beacons renders correc
</LeftPanelLiveShareWarning>
`;
exports[`<LeftPanelLiveShareWarning /> when user has live beacons renders correctly when not minimized 1`] = `
exports[`<LeftPanelLiveShareWarning /> when user has live location monitor renders correctly when not minimized 1`] = `
<LeftPanelLiveShareWarning>
<div
className="mx_LeftPanelLiveShareWarning"

View file

@ -1,5 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<RoomLiveShareWarning /> when user has live beacons renders correctly with one live beacon in room 1`] = `"<div class=\\"mx_RoomLiveShareWarning\\"><div class=\\"mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon\\"></div><span class=\\"mx_RoomLiveShareWarning_label\\">You are sharing your live location</span><span data-test-id=\\"room-live-share-expiry\\" class=\\"mx_RoomLiveShareWarning_expiry\\">1h left</span><button data-test-id=\\"room-live-share-stop-sharing\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger\\">Stop sharing</button></div>"`;
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available renders correctly with one live beacon in room 1`] = `"<div class=\\"mx_RoomLiveShareWarning\\"><div class=\\"mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon\\"></div><span class=\\"mx_RoomLiveShareWarning_label\\">You are sharing your live location</span><span data-test-id=\\"room-live-share-expiry\\" class=\\"mx_RoomLiveShareWarning_expiry\\">1h left</span><button data-test-id=\\"room-live-share-stop-sharing\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger\\">Stop sharing</button></div>"`;
exports[`<RoomLiveShareWarning /> when user has live beacons renders correctly with two live beacons in room 1`] = `"<div class=\\"mx_RoomLiveShareWarning\\"><div class=\\"mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon\\"></div><span class=\\"mx_RoomLiveShareWarning_label\\">You are sharing your live location</span><span data-test-id=\\"room-live-share-expiry\\" class=\\"mx_RoomLiveShareWarning_expiry\\">12h left</span><button data-test-id=\\"room-live-share-stop-sharing\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger\\">Stop sharing</button></div>"`;
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available renders correctly with two live beacons in room 1`] = `"<div class=\\"mx_RoomLiveShareWarning\\"><div class=\\"mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon\\"></div><span class=\\"mx_RoomLiveShareWarning_label\\">You are sharing your live location</span><span data-test-id=\\"room-live-share-expiry\\" class=\\"mx_RoomLiveShareWarning_expiry\\">12h left</span><button data-test-id=\\"room-live-share-stop-sharing\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger\\">Stop sharing</button></div>"`;

View file

@ -17,6 +17,7 @@ limitations under the License.
import { Room, Beacon, BeaconEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { makeBeaconContent } from "matrix-js-sdk/src/content-helpers";
import { M_BEACON, M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon";
import { logger } from "matrix-js-sdk/src/logger";
import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../src/stores/OwnBeaconStore";
import {
@ -160,6 +161,7 @@ describe('OwnBeaconStore', () => {
mockClient.sendEvent.mockClear().mockResolvedValue({ event_id: '1' });
jest.spyOn(global.Date, 'now').mockReturnValue(now);
jest.spyOn(OwnBeaconStore.instance, 'emit').mockRestore();
jest.spyOn(logger, 'error').mockRestore();
});
afterEach(async () => {
@ -600,24 +602,66 @@ describe('OwnBeaconStore', () => {
// stop watching location
expect(geolocation.clearWatch).toHaveBeenCalled();
expect(store.isMonitoringLiveLocation).toEqual(false);
});
it('starts watching position when user starts having live beacons', async () => {
makeRoomsWithStateEvents([]);
await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
addNewBeaconAndEmit(alicesRoom1BeaconInfo);
describe('when store is initialised with live beacons', () => {
it('starts watching position', async () => {
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
const store = await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
expect(geolocation.watchPosition).toHaveBeenCalled();
expect(store.isMonitoringLiveLocation).toEqual(true);
});
it('kills live beacon when geolocation is unavailable', async () => {
const errorLogSpy = jest.spyOn(logger, 'error').mockImplementation(() => { });
// remove the mock we set
// @ts-ignore
navigator.geolocation = undefined;
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
const store = await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
expect(store.isMonitoringLiveLocation).toEqual(false);
expect(errorLogSpy).toHaveBeenCalledWith('Geolocation failed', "Unavailable");
});
it('kills live beacon when geolocation permissions are not granted', async () => {
// similar case to the test above
// but these errors are handled differently
// above is thrown by element, this passed to error callback by geolocation
// return only a permission denied error
geolocation.watchPosition.mockImplementation(watchPositionMockImplementation(
[0], [1]),
);
const errorLogSpy = jest.spyOn(logger, 'error').mockImplementation(() => { });
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
const store = await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
expect(store.isMonitoringLiveLocation).toEqual(false);
expect(errorLogSpy).toHaveBeenCalledWith('Geolocation failed', "PermissionDenied");
});
});
describe('adding a new beacon', () => {
it('publishes position for new beacon immediately', async () => {
makeRoomsWithStateEvents([]);
await makeOwnBeaconStore();
const store = await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
@ -626,6 +670,44 @@ describe('OwnBeaconStore', () => {
await flushPromisesWithFakeTimers();
expect(mockClient.sendEvent).toHaveBeenCalled();
expect(store.isMonitoringLiveLocation).toEqual(true);
});
it('kills live beacons when geolocation is unavailable', async () => {
jest.spyOn(logger, 'error').mockImplementation(() => { });
// @ts-ignore
navigator.geolocation = undefined;
makeRoomsWithStateEvents([]);
const store = await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
addNewBeaconAndEmit(alicesRoom1BeaconInfo);
// wait for store to settle
await flushPromisesWithFakeTimers();
// stop beacon
expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalled();
expect(store.isMonitoringLiveLocation).toEqual(false);
});
it('publishes position for new beacon immediately when there were already live beacons', async () => {
makeRoomsWithStateEvents([alicesRoom2BeaconInfo]);
await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
expect(mockClient.sendEvent).toHaveBeenCalledTimes(1);
addNewBeaconAndEmit(alicesRoom1BeaconInfo);
// wait for store to settle
await flushPromisesWithFakeTimers();
expect(geolocation.getCurrentPosition).toHaveBeenCalled();
// once for original event,
// then both live beacons get current position published
// after new beacon is added
expect(mockClient.sendEvent).toHaveBeenCalledTimes(3);
});
});
it('publishes subsequent positions', async () => {
@ -650,6 +732,57 @@ describe('OwnBeaconStore', () => {
expect(mockClient.sendEvent).toHaveBeenCalledTimes(3);
});
it('stops live beacons when geolocation permissions are revoked', async () => {
jest.spyOn(logger, 'error').mockImplementation(() => { });
// return two good positions, then a permission denied error
geolocation.watchPosition.mockImplementation(watchPositionMockImplementation(
[0, 1000, 3000], [0, 0, 1]),
);
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
expect(mockClient.sendEvent).toHaveBeenCalledTimes(0);
const store = await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
jest.advanceTimersByTime(5000);
// first two events were sent successfully
expect(mockClient.sendEvent).toHaveBeenCalledTimes(2);
// stop beacon
expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalled();
expect(store.isMonitoringLiveLocation).toEqual(false);
});
it('keeps sharing positions when geolocation has a non fatal error', async () => {
const errorLogSpy = jest.spyOn(logger, 'error').mockImplementation(() => { });
// return good position, timeout error, good position
geolocation.watchPosition.mockImplementation(watchPositionMockImplementation(
[0, 1000, 3000], [0, 3, 0]),
);
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
expect(mockClient.sendEvent).toHaveBeenCalledTimes(0);
const store = await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
jest.advanceTimersByTime(5000);
// two good locations were sent
expect(mockClient.sendEvent).toHaveBeenCalledTimes(2);
// still sharing
expect(mockClient.unstable_setLiveBeacon).not.toHaveBeenCalled();
expect(store.isMonitoringLiveLocation).toEqual(true);
expect(errorLogSpy).toHaveBeenCalledWith('Geolocation failed', 'error message');
});
it('publishes last known position after 30s of inactivity', async () => {
geolocation.watchPosition.mockImplementation(
watchPositionMockImplementation([0]),

View file

@ -14,11 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MockedObject } from "jest-mock";
import { makeBeaconInfoContent, makeBeaconContent } from "matrix-js-sdk/src/content-helpers";
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";
import { getMockGeolocationPositionError } from "./location";
type InfoContentProps = {
timeout: number;
@ -150,16 +152,31 @@ export const mockGeolocation = (): MockedObject<Geolocation> => {
* ```
* will call the provided handler with a mock position at
* next tick, 1000ms, 6000ms, 6050ms
*
* to produce errors provide an array of error codes
* that will be applied to the delay with the same index
* eg:
* ```
* // return two good positions, then a permission denied error
* geolocation.watchPosition.mockImplementation(watchPositionMockImplementation(
* [0, 1000, 3000], [0, 0, 1]),
* );
* ```
* See for error codes: https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError
*/
export const watchPositionMockImplementation = (delays: number[]) => {
return (callback: PositionCallback) => {
export const watchPositionMockImplementation = (delays: number[], errorCodes: number[] = []) => {
return (callback: PositionCallback, error: PositionErrorCallback) => {
const position = makeGeolocationPosition({});
let totalDelay = 0;
delays.map(delayMs => {
delays.map((delayMs, index) => {
totalDelay += delayMs;
const timeout = setTimeout(() => {
if (errorCodes[index]) {
error(getMockGeolocationPositionError(errorCodes[index], 'error message'));
} else {
callback({ ...position, timestamp: position.timestamp + totalDelay });
}
}, totalDelay);
return timeout;
});

View file

@ -1,5 +1,6 @@
export * from './beacon';
export * from './client';
export * from './location';
export * from './platform';
export * from './test-utils';
// TODO @@TR: Export voice.ts, which currently isn't exported here because it causes all tests to depend on skinning

View file

@ -48,3 +48,11 @@ export const makeLocationEvent = (geoUri: string, assetType?: LocationAssetType)
},
);
};
// https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError
export const getMockGeolocationPositionError = (code: number, message: string): GeolocationPositionError => ({
code, message,
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3,
});

View file

@ -24,20 +24,16 @@ import {
watchPosition,
} from "../../../src/utils/beacon";
import { getCurrentPosition } from "../../../src/utils/beacon/geolocation";
import { makeGeolocationPosition, mockGeolocation } from "../../test-utils/beacon";
import {
makeGeolocationPosition,
mockGeolocation,
getMockGeolocationPositionError,
} from "../../test-utils";
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();
});