Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Device Manager] Parse user agents (PSG-762) #7247

Merged
merged 16 commits into from
Sep 30, 2022
1 change: 1 addition & 0 deletions changelog.d/7247.wip
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Device Manager] Parse user agents
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is strange to introduce both unstable and stable keys in the same PR.
You should use only unstable, then when the MSC is merged, use both, then after a while, remove unstable, that the servers should not used anymore.
If the MSC is already merged, you can directly use the stable key.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's say we used only unstable one. MSC is merged and server is not using unstable one anymore and sending us the stable one. But I didn't updated EA in my device. So we will miss this field, no?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my understanding, the server has to use both stable and unstable field for a limited period of time.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See in the doc:

Implementations MUST NOT use stable endpoints before the MSC has completed FCP. Once that has occurred, implementations are allowed to use stable endpoints, but are not required to.

This is about endpoint, but I think the same rule applies for Json key.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will ask this to Backend team. I would be happy if we can get rid of this. For the record; we also have them for live location and polls.

) : DatedObject {

override val date: Long
get() = lastSeenTs ?: 0

fun getBestLastSeenUserAgent() = lastSeenUserAgent ?: unstableLastSeenUserAgent
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ data class DeviceFullInfo(
val roomEncryptionTrustLevel: RoomEncryptionTrustLevel,
val isInactive: Boolean,
val isCurrentDevice: Boolean,
val deviceExtendedInfo: DeviceExtendedInfo,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<DeviceFullInfo>> {
Expand Down Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String>): Boolean {
return browserSegments.lastOrNull()?.startsWith("Firefox").orFalse()
}

private fun getBrowserVersion(browserSegments: List<String>, 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<String>): Boolean {
return browserSegments.lastOrNull()?.startsWith("Edge").orFalse()
}

private fun isSafari(browserSegments: List<String>): Boolean {
return browserSegments.lastOrNull()?.startsWith("Safari").orFalse() &&
browserSegments.getOrNull(browserSegments.size - 2)?.startsWith("Version").orFalse()
}

private fun isMobile(browserSegments: List<String>): Boolean {
return browserSegments.getOrNull(browserSegments.size - 2)?.startsWith("Mobile").orFalse()
}

private fun getMobileBrowserName(browserSegments: List<String>): String? {
val possibleBrowserName = browserSegments.getOrNull(browserSegments.size - 3)?.split("/")?.firstOrNull()
return if (possibleBrowserName == "Version") {
"Safari"
} else {
possibleBrowserName
}
}

private fun getRegularBrowserName(browserSegments: List<String>): 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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: this will probably change when we will use the Rust SDK.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I couldn't find safer way for now :/ I can make this keyword in a const val to be safer. But then again we can't parse non-updated clients.


// 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/"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<DeviceFullInfo> {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -242,14 +243,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)
Expand Down
Loading