diff --git a/package.json b/package.json index 82b26a93f5..d9f5fa3188 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@sentry/browser": "^6.11.0", "@sentry/tracing": "^6.11.0", "@types/geojson": "^7946.0.8", + "@types/ua-parser-js": "^0.7.36", "await-lock": "^2.1.0", "blurhash": "^1.1.3", "browser-request": "^0.3.3", @@ -112,6 +113,7 @@ "rfc4648": "^1.4.0", "sanitize-html": "^2.3.2", "tar-js": "^0.3.0", + "ua-parser-js": "^1.0.2", "url": "^0.11.0", "what-input": "^5.2.10", "zxcvbn": "^4.4.2" diff --git a/src/utils/device/parseUserAgent.ts b/src/utils/device/parseUserAgent.ts index 32c57b7624..9d9d71e6f7 100644 --- a/src/utils/device/parseUserAgent.ts +++ b/src/utils/device/parseUserAgent.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import UAParser from 'ua-parser-js'; + export enum DeviceType { Desktop = 'Desktop', Mobile = 'Mobile', @@ -26,20 +28,86 @@ export type ExtendedDeviceInformation = { deviceModel?: string; // eg Android 11 deviceOperatingSystem?: string; - // eg Firefox - clientName?: string; - // eg 1.1.0 - clientVersion?: string; + // eg Firefox 1.1.0 + client?: string; }; +// Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00) +const IOS_KEYWORD = "; iOS "; +const BROWSER_KEYWORD = "Mozilla/"; + +const getDeviceType = ( + userAgent: string, + device: UAParser.IDevice, + browser: UAParser.IBrowser, + operatingSystem: UAParser.IOS, +): DeviceType => { + if (browser.name === 'Electron') { + return DeviceType.Desktop; + } + if (!!browser.name) { + return DeviceType.Web; + } + if ( + device.type === 'mobile' || + operatingSystem.name?.includes('Android') || + userAgent.indexOf(IOS_KEYWORD) > -1 + ) { + return DeviceType.Mobile; + } + return DeviceType.Unknown; +}; + +/** + * Some mobile model and OS strings are not recognised + * by the UA parsing library + * check they exist by hand + */ +const checkForCustomValues = (userAgent: string): { + customDeviceModel?: string; + customDeviceOS?: string; +} => { + if (userAgent.includes(BROWSER_KEYWORD)) { + return {}; + } + + const mightHaveDevice = userAgent.includes('('); + if (!mightHaveDevice) { + return {}; + } + const deviceInfoSegments = userAgent.substring(userAgent.indexOf('(') + 1).split('; '); + const customDeviceModel = deviceInfoSegments[0] || undefined; + const customDeviceOS = deviceInfoSegments[1] || undefined; + return { customDeviceModel, customDeviceOS }; +}; + +const concatenateNameAndVersion = (name?: string, version?: string): string | undefined => + name && [name, version].filter(Boolean).join(' '); + export const parseUserAgent = (userAgent?: string): ExtendedDeviceInformation => { if (!userAgent) { return { deviceType: DeviceType.Unknown, }; } - // @TODO(kerrya) not yet implemented + + const parser = new UAParser(userAgent); + + const browser = parser.getBrowser(); + const device = parser.getDevice(); + const operatingSystem = parser.getOS(); + + const deviceOperatingSystem = concatenateNameAndVersion(operatingSystem.name, operatingSystem.version); + const deviceModel = concatenateNameAndVersion(device.vendor, device.model); + const client = concatenateNameAndVersion(browser.name, browser.major || browser.version); + + const { customDeviceModel, customDeviceOS } = checkForCustomValues(userAgent); + const deviceType = getDeviceType(userAgent, device, browser, operatingSystem); + return { - deviceType: DeviceType.Unknown, + deviceType, + deviceModel: deviceModel || customDeviceModel, + deviceOperatingSystem: deviceOperatingSystem || customDeviceOS, + client, }; }; diff --git a/test/utils/device/parseUserAgent-test.ts b/test/utils/device/parseUserAgent-test.ts index e98fdc26ed..9171a2a755 100644 --- a/test/utils/device/parseUserAgent-test.ts +++ b/test/utils/device/parseUserAgent-test.ts @@ -14,7 +14,107 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { DeviceType, parseUserAgent } from "../../../src/utils/device/parseUserAgent"; +import { DeviceType, ExtendedDeviceInformation, parseUserAgent } from "../../../src/utils/device/parseUserAgent"; + +const makeDeviceExtendedInfo = ( + deviceType: DeviceType, + deviceModel?: string, + deviceOperatingSystem?: string, + clientName?: string, + clientVersion?: string, +): ExtendedDeviceInformation => ({ + deviceType, + deviceModel, + deviceOperatingSystem, + client: clientName && [clientName, clientVersion].filter(Boolean).join(' '), +}); + +/* eslint-disable max-len */ +const ANDROID_UA = [ + // New User Agent Implementation + "Element dbg/1.5.0-dev (Xiaomi Mi 9T; Android 11; RKQ1.200826.002 test-keys; Flavour GooglePlay; MatrixAndroidSdk2 1.5.2)", + "Element/1.5.0 (Samsung SM-G960F; Android 6.0.1; RKQ1.200826.002; Flavour FDroid; MatrixAndroidSdk2 1.5.2)", + "Element/1.5.0 (Google Nexus 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)", + "Element/1.5.0 (Google (Nexus) 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)", + "Element/1.5.0 (Google (Nexus) (5); Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)", + // Legacy User Agent Implementation + "Element/1.0.0 (Linux; U; Android 6.0.1; SM-A510F Build/MMB29; Flavour GPlay; MatrixAndroidSdk2 1.0)", + "Element/1.0.0 (Linux; Android 7.0; SM-G610M Build/NRD90M; Flavour GPlay; MatrixAndroidSdk2 1.0)", +]; + +const ANDROID_EXPECTED_RESULT = [ + makeDeviceExtendedInfo(DeviceType.Mobile, "Xiaomi Mi 9T", "Android 11"), + makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-G960F", "Android 6.0.1"), + makeDeviceExtendedInfo(DeviceType.Mobile, "LG Nexus 5", "Android 7.0"), + makeDeviceExtendedInfo(DeviceType.Mobile, "Google (Nexus) 5", "Android 7.0"), + makeDeviceExtendedInfo(DeviceType.Mobile, "Google (Nexus) (5)", "Android 7.0"), + makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-A510F", "Android 6.0.1"), + makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-G610M", "Android 7.0"), +]; + +const IOS_UA = [ + "Element/1.8.21 (iPhone; iOS 15.2; Scale/3.00)", + "Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00)", + "Element/1.8.21 (iPad Pro (11-inch); iOS 15.2; Scale/3.00)", + "Element/1.8.21 (iPad Pro (12.9-inch) (3rd generation); iOS 15.2; Scale/3.00)", +]; +const IOS_EXPECTED_RESULT = [ + makeDeviceExtendedInfo(DeviceType.Mobile, "Apple iPhone", "iOS 15.2"), + makeDeviceExtendedInfo(DeviceType.Mobile, "Apple iPhone XS Max", "iOS 15.2"), + makeDeviceExtendedInfo(DeviceType.Mobile, "iPad Pro (11-inch)", "iOS 15.2"), + makeDeviceExtendedInfo(DeviceType.Mobile, "iPad Pro (12.9-inch) (3rd generation)", "iOS 15.2"), +]; +const DESKTOP_UA = [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102" + + " Electron/20.1.1 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102 Electron/20.1.1 Safari/537.36", +]; +const DESKTOP_EXPECTED_RESULT = [ + makeDeviceExtendedInfo(DeviceType.Desktop, undefined, "Mac OS 10.15.7", "Electron", "20"), + makeDeviceExtendedInfo(DeviceType.Desktop, undefined, "Windows 10", "Electron", "20"), +]; + +const WEB_UA = [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:39.0) Gecko/20100101 Firefox/39.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/600.3.18 (KHTML, like Gecko) Version/8.0.3 Safari/600.3.18", + "Mozilla/5.0 (Windows NT 6.0; rv:40.0) Gecko/20100101 Firefox/40.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246", + // using mobile browser + "Mozilla/5.0 (iPad; CPU OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4", + "Mozilla/5.0 (iPhone; CPU iPhone OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4", + "Mozilla/5.0 (Linux; Android 9; SM-G973U Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36", +]; + +const WEB_EXPECTED_RESULT = [ + makeDeviceExtendedInfo(DeviceType.Web, undefined, "Mac OS 10.15.7", "Chrome", "104"), + makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows 10", "Chrome", "104"), + makeDeviceExtendedInfo(DeviceType.Web, undefined, "Mac OS 10.10", "Firefox", "39"), + makeDeviceExtendedInfo(DeviceType.Web, undefined, "Mac OS 10.10.2", "Safari", "8"), + makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows Vista", "Firefox", "40"), + makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows 10", "Edge", "12"), + // using mobile browser + makeDeviceExtendedInfo(DeviceType.Web, "Apple iPad", "iOS 8.4.1", "Mobile Safari", "8"), + makeDeviceExtendedInfo(DeviceType.Web, "Apple iPhone", "iOS 8.4.1", "Mobile Safari", "8"), + makeDeviceExtendedInfo(DeviceType.Web, "Samsung SM-G973U", "Android 9", "Chrome", "69"), + +]; + +const MISC_UA = [ + "AppleTV11,1/11.1", + "Curl Client/1.0", + "banana", + "", +]; + +const MISC_EXPECTED_RESULT = [ + makeDeviceExtendedInfo(DeviceType.Unknown, "Apple Apple TV", undefined, undefined, undefined), + makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined), + makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined), + makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined), +]; +/* eslint-disable max-len */ describe('parseUserAgent()', () => { it('returns deviceType unknown when user agent is falsy', () => { @@ -22,4 +122,24 @@ describe('parseUserAgent()', () => { deviceType: DeviceType.Unknown, }); }); + + type TestCase = [string, ExtendedDeviceInformation]; + + const testPlatform = (platform: string, userAgents: string[], results: ExtendedDeviceInformation[]): void => { + const testCases: TestCase[] = userAgents.map((userAgent, index) => [userAgent, results[index]]); + + describe(platform, () => { + it.each( + testCases, + )('Parses user agent correctly - %s', (userAgent, expectedResult) => { + expect(parseUserAgent(userAgent)).toEqual(expectedResult); + }); + }); + }; + + testPlatform('Android', ANDROID_UA, ANDROID_EXPECTED_RESULT); + testPlatform('iOS', IOS_UA, IOS_EXPECTED_RESULT); + testPlatform('Desktop', DESKTOP_UA, DESKTOP_EXPECTED_RESULT); + testPlatform('Web', WEB_UA, WEB_EXPECTED_RESULT); + testPlatform('Misc', MISC_UA, MISC_EXPECTED_RESULT); }); diff --git a/yarn.lock b/yarn.lock index 965d66f09c..eced1c98ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2325,6 +2325,11 @@ dependencies: "@types/jest" "*" +"@types/ua-parser-js@^0.7.36": + version "0.7.36" + resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz#9bd0b47f26b5a3151be21ba4ce9f5fa457c5f190" + integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ== + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" @@ -9197,6 +9202,11 @@ ua-parser-js@^0.7.30: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.31.tgz#649a656b191dffab4f21d5e053e27ca17cbff5c6" integrity sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ== +ua-parser-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.2.tgz#e2976c34dbfb30b15d2c300b2a53eac87c57a775" + integrity sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"