diff --git a/changelog.d/7247.wip b/changelog.d/7247.wip new file mode 100644 index 0000000000..8f2a447742 --- /dev/null +++ b/changelog.d/7247.wip @@ -0,0 +1 @@ +[Device Manager] Parse user agents diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/model/DeviceInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/model/DeviceInfo.kt index b144069b99..500d016002 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/model/DeviceInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/model/DeviceInfo.kt @@ -52,9 +52,17 @@ data class DeviceInfo( * The last ip address. */ @Json(name = "last_seen_ip") - val lastSeenIp: String? = null + val lastSeenIp: String? = null, + + @Json(name = "org.matrix.msc3852.last_seen_user_agent") + val unstableLastSeenUserAgent: String? = null, + + @Json(name = "last_seen_user_agent") + val lastSeenUserAgent: String? = null, ) : DatedObject { override val date: Long get() = lastSeenTs ?: 0 + + fun getBestLastSeenUserAgent() = lastSeenUserAgent ?: unstableLastSeenUserAgent } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceExtendedInfo.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceExtendedInfo.kt new file mode 100644 index 0000000000..24e4606ca7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceExtendedInfo.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +package im.vector.app.features.settings.devices.v2 + +import im.vector.app.features.settings.devices.v2.list.DeviceType + +data class DeviceExtendedInfo( + /** + * One of MOBILE, WEB, DESKTOP or UNKNOWN. + */ + val deviceType: DeviceType, + /** + * i.e. Google Pixel 6. + */ + val deviceModel: String? = null, + /** + * i.e. Android 11. + */ + val deviceOperatingSystem: String? = null, + /** + * i.e. Element Nightly. + */ + val clientName: String? = null, + /** + * i.e. 1.5.0. + */ + val clientVersion: String? = null, +) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt index 373df53b1b..445eb6226f 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt @@ -26,4 +26,5 @@ data class DeviceFullInfo( val roomEncryptionTrustLevel: RoomEncryptionTrustLevel, val isInactive: Boolean, val isCurrentDevice: Boolean, + val deviceExtendedInfo: DeviceExtendedInfo, ) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/GetDeviceFullInfoListUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/GetDeviceFullInfoListUseCase.kt index b2341e23f7..0272bea351 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/GetDeviceFullInfoListUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/GetDeviceFullInfoListUseCase.kt @@ -38,6 +38,7 @@ class GetDeviceFullInfoListUseCase @Inject constructor( private val getEncryptionTrustLevelForDeviceUseCase: GetEncryptionTrustLevelForDeviceUseCase, private val getCurrentSessionCrossSigningInfoUseCase: GetCurrentSessionCrossSigningInfoUseCase, private val filterDevicesUseCase: FilterDevicesUseCase, + private val parseDeviceUserAgentUseCase: ParseDeviceUserAgentUseCase, ) { fun execute(filterType: DeviceManagerFilterType, excludeCurrentDevice: Boolean = false): Flow> { @@ -72,7 +73,8 @@ class GetDeviceFullInfoListUseCase @Inject constructor( val roomEncryptionTrustLevel = getEncryptionTrustLevelForDeviceUseCase.execute(currentSessionCrossSigningInfo, cryptoDeviceInfo) val isInactive = checkIfSessionIsInactiveUseCase.execute(deviceInfo.lastSeenTs ?: 0) val isCurrentDevice = currentSessionCrossSigningInfo.deviceId == cryptoDeviceInfo?.deviceId - DeviceFullInfo(deviceInfo, cryptoDeviceInfo, roomEncryptionTrustLevel, isInactive, isCurrentDevice) + val deviceUserAgent = parseDeviceUserAgentUseCase.execute(deviceInfo.getBestLastSeenUserAgent()) + DeviceFullInfo(deviceInfo, cryptoDeviceInfo, roomEncryptionTrustLevel, isInactive, isCurrentDevice, deviceUserAgent) } } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/ParseDeviceUserAgentUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/ParseDeviceUserAgentUseCase.kt new file mode 100644 index 0000000000..f5f1782d82 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/ParseDeviceUserAgentUseCase.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +package im.vector.app.features.settings.devices.v2 + +import im.vector.app.features.settings.devices.v2.list.DeviceType +import org.matrix.android.sdk.api.extensions.orFalse +import javax.inject.Inject + +class ParseDeviceUserAgentUseCase @Inject constructor() { + + fun execute(userAgent: String?): DeviceExtendedInfo { + if (userAgent == null) return createUnknownUserAgent() + + return when { + userAgent.contains(ANDROID_KEYWORD) -> parseAndroidUserAgent(userAgent) + userAgent.contains(IOS_KEYWORD) -> parseIosUserAgent(userAgent) + userAgent.contains(DESKTOP_KEYWORD) -> parseDesktopUserAgent(userAgent) + userAgent.contains(WEB_KEYWORD) -> parseWebUserAgent(userAgent) + else -> createUnknownUserAgent() + } + } + + private fun parseAndroidUserAgent(userAgent: String): DeviceExtendedInfo { + val appName = userAgent.substringBefore("/") + val appVersion = userAgent.substringAfter("/").substringBefore(" (") + val deviceInfoSegments = userAgent.substringAfter("(").substringBeforeLast(")").split("; ") + val deviceModel: String? + val deviceOperatingSystem: String? + if (deviceInfoSegments.firstOrNull() == "Linux") { + val deviceOperatingSystemIndex = deviceInfoSegments.indexOfFirst { it.startsWith("Android") } + deviceOperatingSystem = deviceInfoSegments.getOrNull(deviceOperatingSystemIndex) + deviceModel = deviceInfoSegments.getOrNull(deviceOperatingSystemIndex + 1) + } else { + deviceModel = deviceInfoSegments.getOrNull(0) + deviceOperatingSystem = deviceInfoSegments.getOrNull(1) + } + return DeviceExtendedInfo( + deviceType = DeviceType.MOBILE, + deviceModel = deviceModel, + deviceOperatingSystem = deviceOperatingSystem, + clientName = appName, + clientVersion = appVersion + ) + } + + private fun parseIosUserAgent(userAgent: String): DeviceExtendedInfo { + val appName = userAgent.substringBefore("/") + val appVersion = userAgent.substringAfter("/").substringBefore(" (") + val deviceInfoSegments = userAgent.substringAfter("(").substringBeforeLast(")").split("; ") + val deviceModel = deviceInfoSegments.getOrNull(0) + val deviceOperatingSystem = deviceInfoSegments.getOrNull(1) + return DeviceExtendedInfo( + deviceType = DeviceType.MOBILE, + deviceModel = deviceModel, + deviceOperatingSystem = deviceOperatingSystem, + clientName = appName, + clientVersion = appVersion + ) + } + + private fun parseDesktopUserAgent(userAgent: String): DeviceExtendedInfo { + val browserSegments = userAgent.split(" ") + val (browserName, browserVersion) = when { + isFirefox(browserSegments) -> { + Pair("Firefox", getBrowserVersion(browserSegments, "Firefox")) + } + isEdge(browserSegments) -> { + Pair("Edge", getBrowserVersion(browserSegments, "Edge")) + } + isMobile(browserSegments) -> { + when (val name = getMobileBrowserName(browserSegments)) { + null -> { + Pair(null, null) + } + "Safari" -> { + Pair(name, getBrowserVersion(browserSegments, "Version")) + } + else -> { + Pair(name, getBrowserVersion(browserSegments, name)) + } + } + } + isSafari(browserSegments) -> { + Pair("Safari", getBrowserVersion(browserSegments, "Version")) + } + else -> { + when (val name = getRegularBrowserName(browserSegments)) { + null -> { + Pair(null, null) + } + else -> { + Pair(name, getBrowserVersion(browserSegments, name)) + } + } + } + } + + val deviceOperatingSystemSegments = userAgent.substringAfter("(").substringBefore(")").split("; ") + val deviceOperatingSystem = if (deviceOperatingSystemSegments.getOrNull(1)?.startsWith("Android").orFalse()) { + deviceOperatingSystemSegments.getOrNull(1) + } else { + deviceOperatingSystemSegments.getOrNull(0) + } + return DeviceExtendedInfo( + deviceType = DeviceType.DESKTOP, + deviceModel = null, + deviceOperatingSystem = deviceOperatingSystem, + clientName = browserName, + clientVersion = browserVersion, + ) + } + + private fun parseWebUserAgent(userAgent: String): DeviceExtendedInfo { + return parseDesktopUserAgent(userAgent).copy( + deviceType = DeviceType.WEB + ) + } + + private fun createUnknownUserAgent(): DeviceExtendedInfo { + return DeviceExtendedInfo(DeviceType.UNKNOWN) + } + + private fun isFirefox(browserSegments: List): Boolean { + return browserSegments.lastOrNull()?.startsWith("Firefox").orFalse() + } + + private fun getBrowserVersion(browserSegments: List, browserName: String): String? { + // Chrome/104.0.3497.100 -> 104 + return browserSegments + .find { it.startsWith(browserName) } + ?.split("/") + ?.getOrNull(1) + ?.split(".") + ?.firstOrNull() + } + + private fun isEdge(browserSegments: List): Boolean { + return browserSegments.lastOrNull()?.startsWith("Edge").orFalse() + } + + private fun isSafari(browserSegments: List): Boolean { + return browserSegments.lastOrNull()?.startsWith("Safari").orFalse() && + browserSegments.getOrNull(browserSegments.size - 2)?.startsWith("Version").orFalse() + } + + private fun isMobile(browserSegments: List): Boolean { + return browserSegments.getOrNull(browserSegments.size - 2)?.startsWith("Mobile").orFalse() + } + + private fun getMobileBrowserName(browserSegments: List): String? { + val possibleBrowserName = browserSegments.getOrNull(browserSegments.size - 3)?.split("/")?.firstOrNull() + return if (possibleBrowserName == "Version") { + "Safari" + } else { + possibleBrowserName + } + } + + private fun getRegularBrowserName(browserSegments: List): String? { + return browserSegments.getOrNull(browserSegments.size - 2)?.split("/")?.firstOrNull() + } + + companion object { + // Element dbg/1.5.0-dev (Xiaomi; Mi 9T; Android 11; RKQ1.200826.002 test-keys; Flavour GooglePlay; MatrixAndroidSdk2 1.5.0) + // Legacy : Element/1.0.0 (Linux; U; Android 6.0.1; SM-A510F Build/MMB29; Flavour GPlay; MatrixAndroidSdk2 1.0) + private const val ANDROID_KEYWORD = "; MatrixAndroidSdk2" + + // Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00) + private const val IOS_KEYWORD = "; iOS " + + // 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 + private const val DESKTOP_KEYWORD = " Electron/" + + // Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36 + private const val WEB_KEYWORD = "Mozilla/" + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/GetDeviceFullInfoUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/GetDeviceFullInfoUseCase.kt index 121973a134..42cd49b072 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/GetDeviceFullInfoUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/GetDeviceFullInfoUseCase.kt @@ -19,6 +19,7 @@ package im.vector.app.features.settings.devices.v2.overview import androidx.lifecycle.asFlow import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.settings.devices.v2.DeviceFullInfo +import im.vector.app.features.settings.devices.v2.ParseDeviceUserAgentUseCase import im.vector.app.features.settings.devices.v2.list.CheckIfSessionIsInactiveUseCase import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase import im.vector.app.features.settings.devices.v2.verification.GetEncryptionTrustLevelForDeviceUseCase @@ -34,6 +35,7 @@ class GetDeviceFullInfoUseCase @Inject constructor( private val getCurrentSessionCrossSigningInfoUseCase: GetCurrentSessionCrossSigningInfoUseCase, private val getEncryptionTrustLevelForDeviceUseCase: GetEncryptionTrustLevelForDeviceUseCase, private val checkIfSessionIsInactiveUseCase: CheckIfSessionIsInactiveUseCase, + private val parseDeviceUserAgentUseCase: ParseDeviceUserAgentUseCase, ) { fun execute(deviceId: String): Flow { @@ -49,12 +51,14 @@ class GetDeviceFullInfoUseCase @Inject constructor( val roomEncryptionTrustLevel = getEncryptionTrustLevelForDeviceUseCase.execute(currentSessionCrossSigningInfo, cryptoInfo) val isInactive = checkIfSessionIsInactiveUseCase.execute(info.lastSeenTs ?: 0) val isCurrentDevice = currentSessionCrossSigningInfo.deviceId == cryptoInfo.deviceId + val deviceUserAgent = parseDeviceUserAgentUseCase.execute(info.getBestLastSeenUserAgent()) DeviceFullInfo( deviceInfo = info, cryptoDeviceInfo = cryptoInfo, roomEncryptionTrustLevel = roomEncryptionTrustLevel, isInactive = isInactive, isCurrentDevice = isCurrentDevice, + deviceExtendedInfo = deviceUserAgent, ) } else { null diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt index c8cfdc6d8a..abf3c0ade1 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt @@ -19,6 +19,7 @@ package im.vector.app.features.settings.devices.v2 import android.os.SystemClock import com.airbnb.mvrx.Success import com.airbnb.mvrx.test.MvRxTestRule +import im.vector.app.features.settings.devices.v2.list.DeviceType import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase import im.vector.app.features.settings.devices.v2.verification.CurrentSessionCrossSigningInfo import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase @@ -243,14 +244,16 @@ class DevicesViewModelTest { cryptoDeviceInfo = verifiedCryptoDeviceInfo, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted, isInactive = false, - isCurrentDevice = true + isCurrentDevice = true, + deviceExtendedInfo = DeviceExtendedInfo(DeviceType.MOBILE) ) val deviceFullInfo2 = DeviceFullInfo( deviceInfo = mockk(), cryptoDeviceInfo = unverifiedCryptoDeviceInfo, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Warning, isInactive = true, - isCurrentDevice = false + isCurrentDevice = false, + deviceExtendedInfo = DeviceExtendedInfo(DeviceType.MOBILE) ) val deviceFullInfoList = listOf(deviceFullInfo1, deviceFullInfo2) val deviceFullInfoListFlow = flowOf(deviceFullInfoList) diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/GetDeviceFullInfoListUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/GetDeviceFullInfoListUseCaseTest.kt index 767819fd24..efeb7f91b8 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/GetDeviceFullInfoListUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/GetDeviceFullInfoListUseCaseTest.kt @@ -19,6 +19,7 @@ package im.vector.app.features.settings.devices.v2 import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.features.settings.devices.v2.filter.FilterDevicesUseCase import im.vector.app.features.settings.devices.v2.list.CheckIfSessionIsInactiveUseCase +import im.vector.app.features.settings.devices.v2.list.DeviceType import im.vector.app.features.settings.devices.v2.verification.CurrentSessionCrossSigningInfo import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase import im.vector.app.features.settings.devices.v2.verification.GetEncryptionTrustLevelForDeviceUseCase @@ -53,6 +54,7 @@ class GetDeviceFullInfoListUseCaseTest { private val getEncryptionTrustLevelForDeviceUseCase = mockk() private val getCurrentSessionCrossSigningInfoUseCase = mockk() private val filterDevicesUseCase = mockk() + private val parseDeviceUserAgentUseCase = mockk() private val getDeviceFullInfoListUseCase = GetDeviceFullInfoListUseCase( activeSessionHolder = fakeActiveSessionHolder.instance, @@ -60,6 +62,7 @@ class GetDeviceFullInfoListUseCaseTest { getEncryptionTrustLevelForDeviceUseCase = getEncryptionTrustLevelForDeviceUseCase, getCurrentSessionCrossSigningInfoUseCase = getCurrentSessionCrossSigningInfoUseCase, filterDevicesUseCase = filterDevicesUseCase, + parseDeviceUserAgentUseCase = parseDeviceUserAgentUseCase, ) @Before @@ -87,21 +90,21 @@ class GetDeviceFullInfoListUseCaseTest { lastSeenTs = A_TIMESTAMP_1, isInactive = true, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted, - cryptoDeviceInfo = cryptoDeviceInfo1 + cryptoDeviceInfo = cryptoDeviceInfo1, ) val deviceInfo2 = givenADevicesInfo( deviceId = A_DEVICE_ID_2, lastSeenTs = A_TIMESTAMP_2, isInactive = false, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted, - cryptoDeviceInfo = cryptoDeviceInfo2 + cryptoDeviceInfo = cryptoDeviceInfo2, ) val deviceInfo3 = givenADevicesInfo( deviceId = A_DEVICE_ID_3, lastSeenTs = A_TIMESTAMP_3, isInactive = false, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Warning, - cryptoDeviceInfo = cryptoDeviceInfo3 + cryptoDeviceInfo = cryptoDeviceInfo3, ) val deviceInfoList = listOf(deviceInfo1, deviceInfo2, deviceInfo3) every { fakeFlowSession.liveMyDevicesInfo() } returns flowOf(deviceInfoList) @@ -110,21 +113,24 @@ class GetDeviceFullInfoListUseCaseTest { cryptoDeviceInfo = cryptoDeviceInfo1, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted, isInactive = true, - isCurrentDevice = true + isCurrentDevice = true, + deviceExtendedInfo = DeviceExtendedInfo(DeviceType.MOBILE) ) val expectedResult2 = DeviceFullInfo( deviceInfo = deviceInfo2, cryptoDeviceInfo = cryptoDeviceInfo2, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted, isInactive = false, - isCurrentDevice = false + isCurrentDevice = false, + deviceExtendedInfo = DeviceExtendedInfo(DeviceType.MOBILE) ) val expectedResult3 = DeviceFullInfo( deviceInfo = deviceInfo3, cryptoDeviceInfo = cryptoDeviceInfo3, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Warning, isInactive = false, - isCurrentDevice = false + isCurrentDevice = false, + deviceExtendedInfo = DeviceExtendedInfo(DeviceType.MOBILE) ) val expectedResult = listOf(expectedResult3, expectedResult2, expectedResult1) every { filterDevicesUseCase.execute(any(), any()) } returns expectedResult @@ -186,8 +192,12 @@ class GetDeviceFullInfoListUseCaseTest { val deviceInfo = mockk() every { deviceInfo.deviceId } returns deviceId every { deviceInfo.lastSeenTs } returns lastSeenTs + every { deviceInfo.getBestLastSeenUserAgent() } returns "" every { getEncryptionTrustLevelForDeviceUseCase.execute(any(), cryptoDeviceInfo) } returns roomEncryptionTrustLevel every { checkIfSessionIsInactiveUseCase.execute(lastSeenTs) } returns isInactive + every { parseDeviceUserAgentUseCase.execute(any()) } returns DeviceExtendedInfo( + DeviceType.MOBILE, + ) return deviceInfo } diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/ParseDeviceUserAgentUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/ParseDeviceUserAgentUseCaseTest.kt new file mode 100644 index 0000000000..22a5a7614f --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/ParseDeviceUserAgentUseCaseTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +package im.vector.app.features.settings.devices.v2 + +import im.vector.app.features.settings.devices.v2.list.DeviceType +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val A_USER_AGENT_LIST_FOR_ANDROID = listOf( + // 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)", +) +private val AN_EXPECTED_RESULT_LIST_FOR_ANDROID = listOf( + DeviceExtendedInfo(DeviceType.MOBILE, "Xiaomi Mi 9T", "Android 11", "Element dbg", "1.5.0-dev"), + DeviceExtendedInfo(DeviceType.MOBILE, "Samsung SM-G960F", "Android 6.0.1", "Element", "1.5.0"), + DeviceExtendedInfo(DeviceType.MOBILE, "Google Nexus 5", "Android 7.0", "Element", "1.5.0"), + DeviceExtendedInfo(DeviceType.MOBILE, "Google (Nexus) 5", "Android 7.0", "Element", "1.5.0"), + DeviceExtendedInfo(DeviceType.MOBILE, "Google (Nexus) (5)", "Android 7.0", "Element", "1.5.0"), + DeviceExtendedInfo(DeviceType.MOBILE, "SM-A510F Build/MMB29", "Android 6.0.1", "Element", "1.0.0"), + DeviceExtendedInfo(DeviceType.MOBILE, "SM-G610M Build/NRD90M", "Android 7.0", "Element", "1.0.0"), +) + +private val A_USER_AGENT_LIST_FOR_IOS = listOf( + "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)", +) +private val AN_EXPECTED_RESULT_LIST_FOR_IOS = listOf( + DeviceExtendedInfo(DeviceType.MOBILE, "iPhone", "iOS 15.2", "Element", "1.8.21"), + DeviceExtendedInfo(DeviceType.MOBILE, "iPhone XS Max", "iOS 15.2", "Element", "1.8.21"), + DeviceExtendedInfo(DeviceType.MOBILE, "iPad Pro (11-inch)", "iOS 15.2", "Element", "1.8.21"), + DeviceExtendedInfo(DeviceType.MOBILE, "iPad Pro (12.9-inch) (3rd generation)", "iOS 15.2", + "Element", "1.8.21"), +) + +private val A_USER_AGENT_LIST_FOR_DESKTOP = listOf( + "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", +) +private val AN_EXPECTED_RESULT_LIST_FOR_DESKTOP = listOf( + DeviceExtendedInfo(DeviceType.DESKTOP, null, "Macintosh", "Electron", "20"), + DeviceExtendedInfo(DeviceType.DESKTOP, null, "Windows NT 10.0", "Electron", "20"), +) + +private val A_USER_AGENT_LIST_FOR_WEB = listOf( + "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 (Linux; Android 9; SM-G973U Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36", + "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 (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", + ) +private val AN_EXPECTED_RESULT_LIST_FOR_WEB = listOf( + DeviceExtendedInfo(DeviceType.WEB, null, "Macintosh", "Chrome", "104"), + DeviceExtendedInfo(DeviceType.WEB, null, "Windows NT 10.0", "Chrome", "104"), + DeviceExtendedInfo(DeviceType.WEB, null, "Macintosh", "Firefox", "39"), + DeviceExtendedInfo(DeviceType.WEB, null, "Macintosh", "Safari", "8"), + DeviceExtendedInfo(DeviceType.WEB, null, "Android 9", "Chrome", "69"), + DeviceExtendedInfo(DeviceType.WEB, null, "iPad", "Safari", "8"), + DeviceExtendedInfo(DeviceType.WEB, null, "iPhone", "Safari", "8"), + DeviceExtendedInfo(DeviceType.WEB, null, "Windows NT 6.0", "Firefox", "40"), + DeviceExtendedInfo(DeviceType.WEB, null, "Windows NT 10.0", "Edge", "12"), +) + +private val AN_UNKNOWN_USER_AGENT_LIST = listOf( + "AppleTV11,1/11.1", + "Curl Client/1.0", +) +private val AN_UNKNOWN_USER_AGENT_EXPECTED_RESULT_LIST = listOf( + DeviceExtendedInfo(DeviceType.UNKNOWN, null, null, null, null), + DeviceExtendedInfo(DeviceType.UNKNOWN, null, null, null, null), +) + +class ParseDeviceUserAgentUseCaseTest { + + private val parseDeviceUserAgentUseCase = ParseDeviceUserAgentUseCase() + + @Test + fun `given an Android user agent then it should be parsed as expected`() { + A_USER_AGENT_LIST_FOR_ANDROID.forEachIndexed { index, userAgent -> + parseDeviceUserAgentUseCase.execute(userAgent) shouldBeEqualTo AN_EXPECTED_RESULT_LIST_FOR_ANDROID[index] + } + } + + @Test + fun `given an iOS user agent then it should be parsed as expected`() { + A_USER_AGENT_LIST_FOR_IOS.forEachIndexed { index, userAgent -> + parseDeviceUserAgentUseCase.execute(userAgent) shouldBeEqualTo AN_EXPECTED_RESULT_LIST_FOR_IOS[index] + } + } + + @Test + fun `given a Desktop user agent then it should be parsed as expected`() { + A_USER_AGENT_LIST_FOR_DESKTOP.forEachIndexed { index, userAgent -> + parseDeviceUserAgentUseCase.execute(userAgent) shouldBeEqualTo AN_EXPECTED_RESULT_LIST_FOR_DESKTOP[index] + } + } + + @Test + fun `given a Web user agent then it should be parsed as expected`() { + A_USER_AGENT_LIST_FOR_WEB.forEachIndexed { index, userAgent -> + parseDeviceUserAgentUseCase.execute(userAgent) shouldBeEqualTo AN_EXPECTED_RESULT_LIST_FOR_WEB[index] + } + } + + @Test + fun `given an unknown user agent then it should be parsed as expected`() { + AN_UNKNOWN_USER_AGENT_LIST.forEachIndexed { index, userAgent -> + parseDeviceUserAgentUseCase.execute(userAgent) shouldBeEqualTo AN_UNKNOWN_USER_AGENT_EXPECTED_RESULT_LIST[index] + } + } +} diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/filter/FilterDevicesUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/filter/FilterDevicesUseCaseTest.kt index 2bb5168190..3448c7324d 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/filter/FilterDevicesUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/filter/FilterDevicesUseCaseTest.kt @@ -16,7 +16,9 @@ package im.vector.app.features.settings.devices.v2.filter +import im.vector.app.features.settings.devices.v2.DeviceExtendedInfo import im.vector.app.features.settings.devices.v2.DeviceFullInfo +import im.vector.app.features.settings.devices.v2.list.DeviceType import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldContainAll import org.junit.Test @@ -34,7 +36,8 @@ private val activeVerifiedDevice = DeviceFullInfo( ), roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted, isInactive = false, - isCurrentDevice = true + isCurrentDevice = true, + deviceExtendedInfo = DeviceExtendedInfo(DeviceType.MOBILE) ) private val inactiveVerifiedDevice = DeviceFullInfo( deviceInfo = DeviceInfo(deviceId = "INACTIVE_VERIFIED_DEVICE"), @@ -45,7 +48,8 @@ private val inactiveVerifiedDevice = DeviceFullInfo( ), roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted, isInactive = true, - isCurrentDevice = false + isCurrentDevice = false, + deviceExtendedInfo = DeviceExtendedInfo(DeviceType.MOBILE) ) private val activeUnverifiedDevice = DeviceFullInfo( deviceInfo = DeviceInfo(deviceId = "ACTIVE_UNVERIFIED_DEVICE"), @@ -56,7 +60,8 @@ private val activeUnverifiedDevice = DeviceFullInfo( ), roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Warning, isInactive = false, - isCurrentDevice = false + isCurrentDevice = false, + deviceExtendedInfo = DeviceExtendedInfo(DeviceType.MOBILE) ) private val inactiveUnverifiedDevice = DeviceFullInfo( deviceInfo = DeviceInfo(deviceId = "INACTIVE_UNVERIFIED_DEVICE"), @@ -67,7 +72,8 @@ private val inactiveUnverifiedDevice = DeviceFullInfo( ), roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Warning, isInactive = true, - isCurrentDevice = false + isCurrentDevice = false, + deviceExtendedInfo = DeviceExtendedInfo(DeviceType.MOBILE) ) private val devices = listOf( diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/GetDeviceFullInfoUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/GetDeviceFullInfoUseCaseTest.kt index 04cd5fc492..a77f8e81fd 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/GetDeviceFullInfoUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/GetDeviceFullInfoUseCaseTest.kt @@ -18,8 +18,11 @@ package im.vector.app.features.settings.devices.v2.overview import androidx.lifecycle.MutableLiveData import androidx.lifecycle.asFlow +import im.vector.app.features.settings.devices.v2.DeviceExtendedInfo import im.vector.app.features.settings.devices.v2.DeviceFullInfo +import im.vector.app.features.settings.devices.v2.ParseDeviceUserAgentUseCase import im.vector.app.features.settings.devices.v2.list.CheckIfSessionIsInactiveUseCase +import im.vector.app.features.settings.devices.v2.list.DeviceType import im.vector.app.features.settings.devices.v2.verification.CurrentSessionCrossSigningInfo import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase import im.vector.app.features.settings.devices.v2.verification.GetEncryptionTrustLevelForDeviceUseCase @@ -53,12 +56,14 @@ class GetDeviceFullInfoUseCaseTest { private val getEncryptionTrustLevelForDeviceUseCase = mockk() private val checkIfSessionIsInactiveUseCase = mockk() private val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions() + private val parseDeviceUserAgentUseCase = mockk() private val getDeviceFullInfoUseCase = GetDeviceFullInfoUseCase( activeSessionHolder = fakeActiveSessionHolder.instance, getCurrentSessionCrossSigningInfoUseCase = getCurrentSessionCrossSigningInfoUseCase, getEncryptionTrustLevelForDeviceUseCase = getEncryptionTrustLevelForDeviceUseCase, checkIfSessionIsInactiveUseCase = checkIfSessionIsInactiveUseCase, + parseDeviceUserAgentUseCase = parseDeviceUserAgentUseCase, ) @Before @@ -76,7 +81,7 @@ class GetDeviceFullInfoUseCaseTest { // Given val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo() val deviceInfo = DeviceInfo( - lastSeenTs = A_TIMESTAMP + lastSeenTs = A_TIMESTAMP, ) fakeActiveSessionHolder.fakeSession.fakeCryptoService.myDevicesInfoWithIdLiveData = MutableLiveData(Optional(deviceInfo)) fakeActiveSessionHolder.fakeSession.fakeCryptoService.myDevicesInfoWithIdLiveData.givenAsFlow() @@ -87,6 +92,7 @@ class GetDeviceFullInfoUseCaseTest { val isInactive = false val isCurrentDevice = true every { checkIfSessionIsInactiveUseCase.execute(any()) } returns isInactive + every { parseDeviceUserAgentUseCase.execute(any()) } returns DeviceExtendedInfo(DeviceType.MOBILE) // When val deviceFullInfo = getDeviceFullInfoUseCase.execute(A_DEVICE_ID).firstOrNull() @@ -97,7 +103,8 @@ class GetDeviceFullInfoUseCaseTest { cryptoDeviceInfo = cryptoDeviceInfo, roomEncryptionTrustLevel = trustLevel, isInactive = isInactive, - isCurrentDevice = isCurrentDevice + isCurrentDevice = isCurrentDevice, + deviceExtendedInfo = DeviceExtendedInfo(DeviceType.MOBILE) ) verify { fakeActiveSessionHolder.instance.getSafeActiveSession() } verify { getCurrentSessionCrossSigningInfoUseCase.execute() }