Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Device manager - record device client information on app start (PSG-6…
Browse files Browse the repository at this point in the history
…33) (#9314)

* record device client inforamtion events on app start

* matrix-client-information -> matrix_client_information

* fix types

* remove another unused export

* add docs link

* add opt in setting for recording device information
  • Loading branch information
Kerry authored Oct 4, 2022
1 parent bb2f4fb commit 0ded5e0
Show file tree
Hide file tree
Showing 7 changed files with 330 additions and 1 deletion.
46 changes: 46 additions & 0 deletions src/DeviceListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ import { isSecureBackupRequired } from './utils/WellKnownUtils';
import { ActionPayload } from "./dispatcher/payloads";
import { Action } from "./dispatcher/actions";
import { isLoggedIn } from "./utils/login";
import SdkConfig from "./SdkConfig";
import PlatformPeg from "./PlatformPeg";
import { recordClientInformation } from "./utils/device/clientInformation";
import SettingsStore, { CallbackFn } from "./settings/SettingsStore";

const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;

Expand All @@ -60,6 +64,8 @@ export default class DeviceListener {
// The set of device IDs we're currently displaying toasts for
private displayingToastsForDeviceIds = new Set<string>();
private running = false;
private shouldRecordClientInformation = false;
private deviceClientInformationSettingWatcherRef: string | undefined;

public static sharedInstance() {
if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener();
Expand All @@ -76,8 +82,15 @@ export default class DeviceListener {
MatrixClientPeg.get().on(ClientEvent.AccountData, this.onAccountData);
MatrixClientPeg.get().on(ClientEvent.Sync, this.onSync);
MatrixClientPeg.get().on(RoomStateEvent.Events, this.onRoomStateEvents);
this.shouldRecordClientInformation = SettingsStore.getValue('deviceClientInformationOptIn');
this.deviceClientInformationSettingWatcherRef = SettingsStore.watchSetting(
'deviceClientInformationOptIn',
null,
this.onRecordClientInformationSettingChange,
);
this.dispatcherRef = dis.register(this.onAction);
this.recheck();
this.recordClientInformation();
}

public stop() {
Expand All @@ -95,6 +108,9 @@ export default class DeviceListener {
MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.onSync);
MatrixClientPeg.get().removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
}
if (this.deviceClientInformationSettingWatcherRef) {
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
}
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
Expand Down Expand Up @@ -200,6 +216,7 @@ export default class DeviceListener {
private onAction = ({ action }: ActionPayload) => {
if (action !== Action.OnLoggedIn) return;
this.recheck();
this.recordClientInformation();
};

// The server doesn't tell us when key backup is set up, so we poll
Expand Down Expand Up @@ -343,4 +360,33 @@ export default class DeviceListener {
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
}
};

private onRecordClientInformationSettingChange: CallbackFn = (
_originalSettingName, _roomId, _level, _newLevel, newValue,
) => {
const prevValue = this.shouldRecordClientInformation;

this.shouldRecordClientInformation = !!newValue;

if (this.shouldRecordClientInformation && !prevValue) {
this.recordClientInformation();
}
};

private recordClientInformation = async () => {
if (!this.shouldRecordClientInformation) {
return;
}
try {
await recordClientInformation(
MatrixClientPeg.get(),
SdkConfig.get(),
PlatformPeg.get(),
);
} catch (error) {
// this is a best effort operation
// log the error without rethrowing
logger.error('Failed to record client information', error);
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,12 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
level={SettingLevel.ACCOUNT} />
) }
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Sessions") }</span>
<SettingsFlag
name="deviceClientInformationOptIn"
level={SettingLevel.ACCOUNT} />
</div>
</React.Fragment>;
}

Expand Down
3 changes: 2 additions & 1 deletion src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,7 @@
"System font name": "System font name",
"Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)",
"Send analytics data": "Send analytics data",
"Record the client name, version, and url to recognise sessions more easily in session manager": "Record the client name, version, and url to recognise sessions more easily in session manager",
"Never send encrypted messages to unverified sessions from this session": "Never send encrypted messages to unverified sessions from this session",
"Never send encrypted messages to unverified sessions in this room from this session": "Never send encrypted messages to unverified sessions in this room from this session",
"Enable inline URL previews by default": "Enable inline URL previews by default",
Expand Down Expand Up @@ -1569,9 +1570,9 @@
"Okay": "Okay",
"Privacy": "Privacy",
"Share anonymous data to help us identify issues. Nothing personal. No third parties.": "Share anonymous data to help us identify issues. Nothing personal. No third parties.",
"Sessions": "Sessions",
"Where you're signed in": "Where you're signed in",
"Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Manage your signed-in devices below. A device's name is visible to people you communicate with.",
"Sessions": "Sessions",
"Other sessions": "Other sessions",
"For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.",
"Sidebar": "Sidebar",
Expand Down
8 changes: 8 additions & 0 deletions src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,14 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td('Send analytics data'),
default: null,
},
"deviceClientInformationOptIn": {
supportedLevels: [SettingLevel.ACCOUNT],
displayName: _td(
`Record the client name, version, and url ` +
`to recognise sessions more easily in session manager`,
),
default: false,
},
"FTUE.useCaseSelection": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: null,
Expand Down
60 changes: 60 additions & 0 deletions src/utils/device/clientInformation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
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 { MatrixClient } from "matrix-js-sdk/src/client";

import BasePlatform from "../../BasePlatform";
import { IConfigOptions } from "../../IConfigOptions";

const formatUrl = (): string | undefined => {
// don't record url for electron clients
if (window.electron) {
return undefined;
}

// strip query-string and fragment from uri
const url = new URL(window.location.href);

return [
url.host,
url.pathname.replace(/\/$/, ""), // Remove trailing slash if present
].join("");
};

const getClientInformationEventType = (deviceId: string): string =>
`io.element.matrix_client_information.${deviceId}`;

/**
* Record extra client information for the current device
* https://github.com/vector-im/element-meta/blob/develop/spec/matrix_client_information.md
*/
export const recordClientInformation = async (
matrixClient: MatrixClient,
sdkConfig: IConfigOptions,
platform: BasePlatform,
): Promise<void> => {
const deviceId = matrixClient.getDeviceId();
const { brand } = sdkConfig;
const version = await platform.getAppVersion();
const type = getClientInformationEventType(deviceId);
const url = formatUrl();

await matrixClient.setAccountData(type, {
name: brand,
version,
url,
});
};
122 changes: 122 additions & 0 deletions test/DeviceListener-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ limitations under the License.
import { EventEmitter } from "events";
import { mocked } from "jest-mock";
import { Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";

import DeviceListener from "../src/DeviceListener";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
Expand All @@ -27,6 +28,9 @@ import * as BulkUnverifiedSessionsToast from "../src/toasts/BulkUnverifiedSessio
import { isSecretStorageBeingAccessed } from "../src/SecurityManager";
import dis from "../src/dispatcher/dispatcher";
import { Action } from "../src/dispatcher/actions";
import SettingsStore from "../src/settings/SettingsStore";
import { mockPlatformPeg } from "./test-utils";
import { SettingLevel } from "../src/settings/SettingLevel";

// don't litter test console with logs
jest.mock("matrix-js-sdk/src/logger");
Expand All @@ -40,7 +44,10 @@ jest.mock("../src/SecurityManager", () => ({
isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(),
}));

const deviceId = 'my-device-id';

class MockClient extends EventEmitter {
isGuest = jest.fn();
getUserId = jest.fn();
getKeyBackupVersion = jest.fn().mockResolvedValue(undefined);
getRooms = jest.fn().mockReturnValue([]);
Expand All @@ -57,6 +64,8 @@ class MockClient extends EventEmitter {
downloadKeys = jest.fn();
isRoomEncrypted = jest.fn();
getClientWellKnown = jest.fn();
getDeviceId = jest.fn().mockReturnValue(deviceId);
setAccountData = jest.fn();
}
const mockDispatcher = mocked(dis);
const flushPromises = async () => await new Promise(process.nextTick);
Expand All @@ -75,8 +84,12 @@ describe('DeviceListener', () => {

beforeEach(() => {
jest.resetAllMocks();
mockPlatformPeg({
getAppVersion: jest.fn().mockResolvedValue('1.2.3'),
});
mockClient = new MockClient();
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient);
jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false);
});

const createAndStart = async (): Promise<DeviceListener> => {
Expand All @@ -86,6 +99,115 @@ describe('DeviceListener', () => {
return instance;
};

describe('client information', () => {
it('watches device client information setting', async () => {
const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting');
const unwatchSettingSpy = jest.spyOn(SettingsStore, 'unwatchSetting');
const deviceListener = await createAndStart();

expect(watchSettingSpy).toHaveBeenCalledWith(
'deviceClientInformationOptIn', null, expect.any(Function),
);

deviceListener.stop();

expect(unwatchSettingSpy).toHaveBeenCalled();
});

describe('when device client information feature is enabled', () => {
beforeEach(() => {
jest.spyOn(SettingsStore, 'getValue').mockImplementation(
settingName => settingName === 'deviceClientInformationOptIn',
);
});
it('saves client information on start', async () => {
await createAndStart();

expect(mockClient.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});

it('catches error and logs when saving client information fails', async () => {
const errorLogSpy = jest.spyOn(logger, 'error');
const error = new Error('oups');
mockClient.setAccountData.mockRejectedValue(error);

// doesn't throw
await createAndStart();

expect(errorLogSpy).toHaveBeenCalledWith(
'Failed to record client information',
error,
);
});

it('saves client information on logged in action', async () => {
const instance = await createAndStart();

mockClient.setAccountData.mockClear();

// @ts-ignore calling private function
instance.onAction({ action: Action.OnLoggedIn });

await flushPromises();

expect(mockClient.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
});

describe('when device client information feature is disabled', () => {
beforeEach(() => {
jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false);
});

it('does not save client information on start', async () => {
await createAndStart();

expect(mockClient.setAccountData).not.toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});

it('does not save client information on logged in action', async () => {
const instance = await createAndStart();

// @ts-ignore calling private function
instance.onAction({ action: Action.OnLoggedIn });

await flushPromises();

expect(mockClient.setAccountData).not.toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});

it('saves client information after setting is enabled', async () => {
const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting');
await createAndStart();

const [settingName, roomId, callback] = watchSettingSpy.mock.calls[0];
expect(settingName).toEqual('deviceClientInformationOptIn');
expect(roomId).toBeNull();

callback('deviceClientInformationOptIn', null, SettingLevel.DEVICE, SettingLevel.DEVICE, true);

await flushPromises();

expect(mockClient.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
});
});

describe('recheck', () => {
it('does nothing when cross signing feature is not supported', async () => {
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false);
Expand Down
Loading

0 comments on commit 0ded5e0

Please sign in to comment.