From a37682e64d5788028138d26fce76b62c76d5b111 Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Fri, 23 Sep 2022 17:10:15 +0200 Subject: [PATCH 1/6] record device client inforamtion events on app start --- src/DeviceListener.ts | 19 +++++ src/utils/device/clientInformation.ts | 62 +++++++++++++++ test/DeviceListener-test.ts | 50 ++++++++++++ test/utils/device/clientInformation-test.ts | 86 +++++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 src/utils/device/clientInformation.ts create mode 100644 test/utils/device/clientInformation-test.ts diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index cf9af5befc4..c6d920c2eac 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -40,6 +40,9 @@ 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"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -78,6 +81,7 @@ export default class DeviceListener { MatrixClientPeg.get().on(RoomStateEvent.Events, this.onRoomStateEvents); this.dispatcherRef = dis.register(this.onAction); this.recheck(); + this.recordClientInformation(); } public stop() { @@ -200,6 +204,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 @@ -343,4 +348,18 @@ export default class DeviceListener { dis.dispatch({ action: Action.ReportKeyBackupNotEnabled }); } }; + + private recordClientInformation = async () => { + 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); + } + }; } diff --git a/src/utils/device/clientInformation.ts b/src/utils/device/clientInformation.ts new file mode 100644 index 00000000000..9dfa5c03ff4 --- /dev/null +++ b/src/utils/device/clientInformation.ts @@ -0,0 +1,62 @@ +/* +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"; + +export type DeviceClientInformation = { + name?: string; + version?: string; + url?: string; +}; + +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(""); +}; + +export const getClientInformationEventType = (deviceId: string): string => + `io.element.matrix-client-information.${deviceId}`; + +export const recordClientInformation = async ( + matrixClient: MatrixClient, + sdkConfig: IConfigOptions, + platform: BasePlatform, +): Promise => { + 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, + }); +}; diff --git a/test/DeviceListener-test.ts b/test/DeviceListener-test.ts index 06405674416..6e7f68896cc 100644 --- a/test/DeviceListener-test.ts +++ b/test/DeviceListener-test.ts @@ -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"; @@ -27,6 +28,7 @@ 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 { mockPlatformPeg } from "./test-utils"; // don't litter test console with logs jest.mock("matrix-js-sdk/src/logger"); @@ -40,6 +42,8 @@ jest.mock("../src/SecurityManager", () => ({ isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(), })); +const deviceId = 'my-device-id'; + class MockClient extends EventEmitter { getUserId = jest.fn(); getKeyBackupVersion = jest.fn().mockResolvedValue(undefined); @@ -57,6 +61,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); @@ -75,6 +81,9 @@ describe('DeviceListener', () => { beforeEach(() => { jest.resetAllMocks(); + mockPlatformPeg({ + getAppVersion: jest.fn().mockResolvedValue('1.2.3'), + }); mockClient = new MockClient(); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); }); @@ -86,6 +95,47 @@ describe('DeviceListener', () => { return instance; }; + describe('client information', () => { + 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('recheck', () => { it('does nothing when cross signing feature is not supported', async () => { mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false); diff --git a/test/utils/device/clientInformation-test.ts b/test/utils/device/clientInformation-test.ts new file mode 100644 index 00000000000..e6b470437e8 --- /dev/null +++ b/test/utils/device/clientInformation-test.ts @@ -0,0 +1,86 @@ +/* +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 BasePlatform from "../../../src/BasePlatform"; +import { IConfigOptions } from "../../../src/IConfigOptions"; +import { recordClientInformation } from "../../../src/utils/device/clientInformation"; +import { getMockClientWithEventEmitter } from "../../test-utils"; + +describe('recordClientInformation()', () => { + const deviceId = 'my-device-id'; + const version = '1.2.3'; + const isElectron = window.electron; + + const mockClient = getMockClientWithEventEmitter({ + getDeviceId: jest.fn().mockReturnValue(deviceId), + setAccountData: jest.fn(), + }); + + const sdkConfig: IConfigOptions = { + brand: 'Test Brand', + element_call: { url: '' }, + }; + + const platform = { + getAppVersion: jest.fn().mockResolvedValue(version), + } as unknown as BasePlatform; + + beforeEach(() => { + jest.clearAllMocks(); + window.electron = false; + }); + + afterAll(() => { + // restore global + window.electron = isElectron; + }); + + it('saves client information without url for electron clients', async () => { + window.electron = true; + + await recordClientInformation( + mockClient, + sdkConfig, + platform, + ); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix-client-information.${deviceId}`, + { + name: sdkConfig.brand, + version, + url: undefined, + }, + ); + }); + + it('saves client information with url for non-electron clients', async () => { + await recordClientInformation( + mockClient, + sdkConfig, + platform, + ); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix-client-information.${deviceId}`, + { + name: sdkConfig.brand, + version, + url: 'localhost', + }, + ); + }); +}); From 94b027b9c94ef1732b8d1a0d06329ea35fc1797d Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Mon, 26 Sep 2022 10:35:29 +0200 Subject: [PATCH 2/6] matrix-client-information -> matrix_client_information --- src/utils/device/clientInformation.ts | 2 +- test/DeviceListener-test.ts | 4 ++-- test/utils/device/clientInformation-test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/utils/device/clientInformation.ts b/src/utils/device/clientInformation.ts index 9dfa5c03ff4..f45fafbd1e3 100644 --- a/src/utils/device/clientInformation.ts +++ b/src/utils/device/clientInformation.ts @@ -41,7 +41,7 @@ const formatUrl = (): string | undefined => { }; export const getClientInformationEventType = (deviceId: string): string => - `io.element.matrix-client-information.${deviceId}`; + `io.element.matrix_client_information.${deviceId}`; export const recordClientInformation = async ( matrixClient: MatrixClient, diff --git a/test/DeviceListener-test.ts b/test/DeviceListener-test.ts index 6e7f68896cc..466ea39c49b 100644 --- a/test/DeviceListener-test.ts +++ b/test/DeviceListener-test.ts @@ -100,7 +100,7 @@ describe('DeviceListener', () => { await createAndStart(); expect(mockClient.setAccountData).toHaveBeenCalledWith( - `io.element.matrix-client-information.${deviceId}`, + `io.element.matrix_client_information.${deviceId}`, { name: 'Element', url: 'localhost', version: '1.2.3' }, ); }); @@ -130,7 +130,7 @@ describe('DeviceListener', () => { await flushPromises(); expect(mockClient.setAccountData).toHaveBeenCalledWith( - `io.element.matrix-client-information.${deviceId}`, + `io.element.matrix_client_information.${deviceId}`, { name: 'Element', url: 'localhost', version: '1.2.3' }, ); }); diff --git a/test/utils/device/clientInformation-test.ts b/test/utils/device/clientInformation-test.ts index e6b470437e8..0c4a6cf2648 100644 --- a/test/utils/device/clientInformation-test.ts +++ b/test/utils/device/clientInformation-test.ts @@ -58,7 +58,7 @@ describe('recordClientInformation()', () => { ); expect(mockClient.setAccountData).toHaveBeenCalledWith( - `io.element.matrix-client-information.${deviceId}`, + `io.element.matrix_client_information.${deviceId}`, { name: sdkConfig.brand, version, @@ -75,7 +75,7 @@ describe('recordClientInformation()', () => { ); expect(mockClient.setAccountData).toHaveBeenCalledWith( - `io.element.matrix-client-information.${deviceId}`, + `io.element.matrix_client_information.${deviceId}`, { name: sdkConfig.brand, version, From 593465ae04d3f5cb671b3cca994cee9dd9a2376a Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Mon, 26 Sep 2022 11:16:46 +0200 Subject: [PATCH 3/6] fix types --- src/utils/device/clientInformation.ts | 2 +- test/utils/device/clientInformation-test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/device/clientInformation.ts b/src/utils/device/clientInformation.ts index f45fafbd1e3..d12d4cd8ae2 100644 --- a/src/utils/device/clientInformation.ts +++ b/src/utils/device/clientInformation.ts @@ -40,7 +40,7 @@ const formatUrl = (): string | undefined => { ].join(""); }; -export const getClientInformationEventType = (deviceId: string): string => +const getClientInformationEventType = (deviceId: string): string => `io.element.matrix_client_information.${deviceId}`; export const recordClientInformation = async ( diff --git a/test/utils/device/clientInformation-test.ts b/test/utils/device/clientInformation-test.ts index 0c4a6cf2648..628c9729d14 100644 --- a/test/utils/device/clientInformation-test.ts +++ b/test/utils/device/clientInformation-test.ts @@ -31,7 +31,7 @@ describe('recordClientInformation()', () => { const sdkConfig: IConfigOptions = { brand: 'Test Brand', - element_call: { url: '' }, + element_call: { url: '', use_exclusively: false }, }; const platform = { From c85865607fb354dc99345e21a4d528a77306155d Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Mon, 26 Sep 2022 11:25:18 +0200 Subject: [PATCH 4/6] remove another unused export --- src/utils/device/clientInformation.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/utils/device/clientInformation.ts b/src/utils/device/clientInformation.ts index d12d4cd8ae2..83277b11363 100644 --- a/src/utils/device/clientInformation.ts +++ b/src/utils/device/clientInformation.ts @@ -19,12 +19,6 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import BasePlatform from "../../BasePlatform"; import { IConfigOptions } from "../../IConfigOptions"; -export type DeviceClientInformation = { - name?: string; - version?: string; - url?: string; -}; - const formatUrl = (): string | undefined => { // don't record url for electron clients if (window.electron) { From 950f61cdb09d210e4fa424a3ee69fd0a9cb4ca3c Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Tue, 27 Sep 2022 10:26:46 +0200 Subject: [PATCH 5/6] add docs link --- src/utils/device/clientInformation.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/utils/device/clientInformation.ts b/src/utils/device/clientInformation.ts index 83277b11363..32445334f5a 100644 --- a/src/utils/device/clientInformation.ts +++ b/src/utils/device/clientInformation.ts @@ -37,6 +37,10 @@ const formatUrl = (): string | undefined => { 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, From 50df95b80d7162454321b7ee1690dab32e653419 Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Tue, 27 Sep 2022 16:36:53 +0200 Subject: [PATCH 6/6] add opt in setting for recording device information --- src/DeviceListener.ts | 27 ++++ .../tabs/user/SecurityUserSettingsTab.tsx | 6 + src/i18n/strings/en_EN.json | 3 +- src/settings/Settings.tsx | 8 ++ test/DeviceListener-test.ts | 122 ++++++++++++++---- 5 files changed, 140 insertions(+), 26 deletions(-) diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index c6d920c2eac..be440333e27 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -43,6 +43,7 @@ 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; @@ -63,6 +64,8 @@ export default class DeviceListener { // The set of device IDs we're currently displaying toasts for private displayingToastsForDeviceIds = new Set(); private running = false; + private shouldRecordClientInformation = false; + private deviceClientInformationSettingWatcherRef: string | undefined; public static sharedInstance() { if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener(); @@ -79,6 +82,12 @@ 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(); @@ -99,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; @@ -349,7 +361,22 @@ export default class DeviceListener { } }; + 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(), diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index 177c3b4f5ea..91b448eb3b0 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -319,6 +319,12 @@ export default class SecurityUserSettingsTab extends React.Component ) } +
+ { _t("Sessions") } + +
; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9bd1dd8124f..4e59a214265 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", @@ -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", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 5220f9d0604..d62e3c44916 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -739,6 +739,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, diff --git a/test/DeviceListener-test.ts b/test/DeviceListener-test.ts index 466ea39c49b..46f2abcd28d 100644 --- a/test/DeviceListener-test.ts +++ b/test/DeviceListener-test.ts @@ -28,7 +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"); @@ -45,6 +47,7 @@ jest.mock("../src/SecurityManager", () => ({ const deviceId = 'my-device-id'; class MockClient extends EventEmitter { + isGuest = jest.fn(); getUserId = jest.fn(); getKeyBackupVersion = jest.fn().mockResolvedValue(undefined); getRooms = jest.fn().mockReturnValue([]); @@ -86,6 +89,7 @@ describe('DeviceListener', () => { }); mockClient = new MockClient(); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); }); const createAndStart = async (): Promise => { @@ -96,43 +100,111 @@ describe('DeviceListener', () => { }; describe('client information', () => { - it('saves client information on start', async () => { - await createAndStart(); + it('watches device client information setting', async () => { + const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting'); + const unwatchSettingSpy = jest.spyOn(SettingsStore, 'unwatchSetting'); + const deviceListener = await createAndStart(); - expect(mockClient.setAccountData).toHaveBeenCalledWith( - `io.element.matrix_client_information.${deviceId}`, - { name: 'Element', url: 'localhost', version: '1.2.3' }, + expect(watchSettingSpy).toHaveBeenCalledWith( + 'deviceClientInformationOptIn', null, expect.any(Function), ); + + deviceListener.stop(); + + expect(unwatchSettingSpy).toHaveBeenCalled(); }); - 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); + 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' }, + ); + }); - // doesn't throw - await createAndStart(); + 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); - expect(errorLogSpy).toHaveBeenCalledWith( - 'Failed to record client information', - 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' }, + ); + }); }); - it('saves client information on logged in action', async () => { - const instance = await createAndStart(); + 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' }, + ); + }); - mockClient.setAccountData.mockClear(); + 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 }); + // @ts-ignore calling private function + instance.onAction({ action: Action.OnLoggedIn }); - await flushPromises(); + await flushPromises(); - expect(mockClient.setAccountData).toHaveBeenCalledWith( - `io.element.matrix_client_information.${deviceId}`, - { name: 'Element', url: 'localhost', version: '1.2.3' }, - ); + 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' }, + ); + }); }); });