diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.android.kt new file mode 100644 index 0000000..7d37179 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.android.kt @@ -0,0 +1,22 @@ +package ru.fromchat.platform + +import android.os.Build + +private fun String?.trimIfNotBlank(): String? = + this?.trim()?.takeIf { it.isNotEmpty() } + +actual fun currentDeviceInfo(): CurrentDeviceInfo { + val osName = "Android" + val osVersion = Build.VERSION.RELEASE.trimIfNotBlank() ?: Build.VERSION.SDK_INT.toString() + val brand = Build.BRAND.trimIfNotBlank() ?: Build.MANUFACTURER.trimIfNotBlank() + val model = Build.MODEL.trimIfNotBlank() + + return CurrentDeviceInfo( + osName = osName, + osVersion = osVersion, + deviceType = "mobile", + deviceName = model?.let { if (it.contains(brand.orEmpty(), ignoreCase = true)) it else listOfNotNull(brand, it).joinToString(" ") }, + brand = brand, + model = model + ) +} diff --git a/app/shared/src/commonMain/composeResources/drawable/os_linux.svg b/app/shared/src/commonMain/composeResources/drawable/os_linux.svg new file mode 100644 index 0000000..4915de3 --- /dev/null +++ b/app/shared/src/commonMain/composeResources/drawable/os_linux.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/shared/src/commonMain/composeResources/drawable/os_macos.svg b/app/shared/src/commonMain/composeResources/drawable/os_macos.svg new file mode 100644 index 0000000..3745ebe --- /dev/null +++ b/app/shared/src/commonMain/composeResources/drawable/os_macos.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/shared/src/commonMain/composeResources/drawable/os_windows.svg b/app/shared/src/commonMain/composeResources/drawable/os_windows.svg new file mode 100644 index 0000000..96f79c1 --- /dev/null +++ b/app/shared/src/commonMain/composeResources/drawable/os_windows.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index eeec633..063034b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -12,6 +12,7 @@ import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.SIMPLE +import io.ktor.client.request.header import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.pingInterval import io.ktor.client.request.bearerAuth @@ -39,6 +40,7 @@ import com.pr0gramm3r101.utils.crypto.Base64 import ru.fromchat.crypto.IdentityKeyManager import ru.fromchat.crypto.transport.TransportCiphertext import ru.fromchat.crypto.transport.TransportCrypto +import ru.fromchat.platform.currentDeviceInfo /** * Creates a platform-specific HTTP client that supports WebSockets @@ -69,10 +71,23 @@ object ApiClient { pingInterval = 5000.milliseconds } + val currentDevice = currentDeviceInfo() + val osName = currentDevice.osName?.takeIfNotBlank() + val osVersion = currentDevice.osVersion?.takeIfNotBlank() + val brand = currentDevice.brand?.takeIfNotBlank() + val model = currentDevice.model?.takeIfNotBlank() + val userAgent = buildLoginUserAgent( + osName = osName, + osVersion = osVersion, + model = model, + brand = brand + ) + defaultRequest { token?.let { authToken -> bearerAuth(authToken) } + header("User-Agent", userAgent) } // Handle HTTP errors and auth errors globally @@ -98,6 +113,36 @@ object ApiClient { } } + private fun String?.takeIfNotBlank(): String? = + this?.trim()?.takeIf { it.isNotBlank() } + + private fun formatIosVersion(version: String): String = + version + .trim() + .replace("-", "_") + .replace(".", "_") + .trim('_') + + private fun buildLoginUserAgent( + osName: String?, + osVersion: String?, + model: String?, + brand: String? + ): String { + val safeModel = model?.ifBlank { null } ?: brand?.ifBlank { null } ?: "device" + return when { + osName?.contains("ios", ignoreCase = true) == true -> { + val version = osVersion?.let(::formatIosVersion)?.ifBlank { "17_0" } ?: "17_0" + "Mozilla/5.0 (iPhone; CPU iPhone OS $version like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" + } + osName?.contains("android", ignoreCase = true) == true -> { + val version = osVersion?.ifBlank { "10" } ?: "10" + "FromChat/1.0 (Linux; Android $version; $safeModel)" + } + else -> "FromChat/1.0 ($safeModel)" + } + } + @Volatile var token: String? = null diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.kt new file mode 100644 index 0000000..24fedba --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.kt @@ -0,0 +1,12 @@ +package ru.fromchat.platform + +data class CurrentDeviceInfo( + val osName: String? = null, + val osVersion: String? = null, + val deviceType: String? = null, + val deviceName: String? = null, + val brand: String? = null, + val model: String? = null +) + +expect fun currentDeviceInfo(): CurrentDeviceInfo diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt index 5318e9b..c713097 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt @@ -106,11 +106,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.navigation.NavController @@ -148,6 +150,7 @@ import ru.fromchat.materialYou_d import ru.fromchat.password_length_error import ru.fromchat.passwords_dont_match import ru.fromchat.platform.openAppNotificationSettings +import ru.fromchat.platform.currentDeviceInfo import ru.fromchat.settings_account_delete import ru.fromchat.settings_account_delete_confirm_body import ru.fromchat.settings_account_delete_confirm_title @@ -212,6 +215,7 @@ import ru.fromchat.theme import ru.fromchat.ui.Theme import ru.fromchat.ui.dynamicThemeEnabled import ru.fromchat.ui.theme +import coil3.compose.AsyncImage @Composable private fun SettingsListLeadingIcon(imageVector: ImageVector) { @@ -585,15 +589,99 @@ fun SettingsNotificationsScreen(onBack: () -> Unit) { } private fun formatDeviceLine(d: DeviceSessionInfo, fallbackLabel: String): String { - val parts = listOfNotNull(d.deviceName, d.osName, d.browserName).filter { it.isNotBlank() } + val parts = listOfNotNull( + d.deviceName, + d.brand, + d.model, + deviceSessionOsLine(d), + d.browserName, + d.deviceType + ).mapNotNull { it.trim().takeIf { it.isNotBlank() } } + .distinct() return if (parts.isNotEmpty()) parts.joinToString(" • ") else (d.deviceType?.takeIf { it.isNotBlank() } ?: fallbackLabel) } +private fun deviceSessionForCurrentDevice(d: DeviceSessionInfo): DeviceSessionInfo { + if (!d.current) return d + val current = currentDeviceInfo() + + val localOsName = current.osName?.trim()?.takeIf { it.isNotBlank() } + val localOsVersion = current.osVersion?.trim()?.takeIf { it.isNotBlank() } + val localDeviceType = current.deviceType?.trim()?.takeIf { it.isNotBlank() } + val localDeviceName = current.deviceName?.trim()?.takeIf { it.isNotBlank() } + val localBrand = current.brand?.trim()?.takeIf { it.isNotBlank() } + val localModel = current.model?.trim()?.takeIf { it.isNotBlank() } + val remoteDeviceName = d.deviceName?.trim()?.takeIf { it.isNotBlank() } + val remoteBrand = d.brand?.trim()?.takeIf { it.isNotBlank() } + val remoteModel = d.model?.trim()?.takeIf { it.isNotBlank() } + + return d.copy( + osName = localOsName ?: d.osName, + osVersion = localOsVersion ?: d.osVersion, + deviceType = localDeviceType ?: d.deviceType, + deviceName = remoteDeviceName ?: localDeviceName ?: d.deviceName, + brand = remoteBrand ?: localBrand ?: d.brand, + model = remoteModel ?: localModel ?: d.model + ) +} + private fun formatDeviceLastSeen(iso: String?): String { if (iso.isNullOrBlank()) return "—" return iso.replace("T", " ").take(19) } +private fun resolveDeviceOsName(d: DeviceSessionInfo): String? { + val direct = normalizeDeviceOsName(d.osName) + if (direct != null) return direct + return normalizeDeviceOsName(inferDeviceOsFromSession(d)) +} + +private fun normalizeDeviceOsName(raw: String?): String? { + val trimmed = raw?.trim() ?: return null + if (trimmed.isBlank()) return null + val lower = trimmed.lowercase() + return when { + "windows" in lower || "win" in lower -> "Windows" + "mac" in lower || "os x" in lower || "darwin" in lower -> "macOS" + "linux" in lower -> "Linux" + "android" in lower -> "Android" + "ios" in lower || "iphone" in lower || "ipad" in lower -> "iOS" + else -> trimmed + } +} + +private fun inferDeviceOsFromSession(d: DeviceSessionInfo): String? { + val type = d.deviceType?.lowercase().orEmpty() + val name = d.deviceName?.lowercase().orEmpty() + val brand = d.brand?.lowercase().orEmpty() + val model = d.model?.lowercase().orEmpty() + val browser = d.browserName?.lowercase().orEmpty() + val isMobile = "mobile" in type || "tablet" in type || "phone" in type + return when { + isMobile && (brand.contains("apple") || name.contains("iphone") || name.contains("ipad") || model.contains("iphone") || model.contains("ipad") || browser.contains("ios")) -> "iOS" + isMobile && (brand.contains("android") || name.contains("android") || model.contains("android") || browser.contains("android")) -> "Android" + isMobile && (brand.contains("samsung") || brand.contains("xiaomi") || brand.contains("huawei") || brand.contains("oppo") || brand.contains("vivo") || brand.contains("pixel") || brand.contains("oneplus") || brand.contains("google") || brand.contains("lg") || brand.contains("motorola") || brand.contains("honor") || brand.contains("realme")) -> "Android" + "android" in browser || "android" in brand || "android" in model -> "Android" + brand.contains("apple") || model.contains("iphone") || model.contains("ipad") || browser.contains("ios") || browser.contains("iphone") || browser.contains("ipad") -> "iOS" + else -> null + } +} + +private fun deviceSessionOsLine(d: DeviceSessionInfo): String? { + val os = resolveDeviceOsName(d) ?: return null + val version = d.osVersion?.trim().orEmpty() + return if (version.isBlank()) os else "$os $version" +} + +private fun deviceSessionLogoResource(d: DeviceSessionInfo): String? { + return when (resolveDeviceOsName(d)?.lowercase()) { + "windows", "windows nt" -> "drawable/os_windows.svg" + "mac", "mac os", "macos" -> "drawable/os_macos.svg" + "linux" -> "drawable/os_linux.svg" + else -> null + } +} + private fun deviceHeadline(d: DeviceSessionInfo, fallback: String): String { d.deviceName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } val brandModel = listOfNotNull(d.brand?.trim(), d.model?.trim()) @@ -604,16 +692,25 @@ private fun deviceHeadline(d: DeviceSessionInfo, fallback: String): String { } private fun deviceDetailLine(d: DeviceSessionInfo): String? { + val deviceType = d.deviceType?.trim()?.takeIf { it.isNotBlank() && !it.equals("other", ignoreCase = true) } + val brand = d.brand?.trim()?.takeIf { it.isNotBlank() } + val model = d.model?.trim()?.takeIf { it.isNotBlank() } + val browser = d.browserName?.trim() + ?.takeIf { it.isNotBlank() && !it.equals("other", ignoreCase = true) && !it.equals("desktop", ignoreCase = true) } + val parts = buildList { - d.osName?.takeIf { it.isNotBlank() }?.let { add(it.trim()) } - d.browserName?.takeIf { it.isNotBlank() }?.let { add(it.trim()) } + deviceSessionOsLine(d)?.let { add(it.trim()) } + brand?.let { add(it) } + model?.let { add(it) } + deviceType?.let { add(it) } + browser?.let { add(it) } } - return if (parts.isNotEmpty()) parts.joinToString(" · ") else null + return if (parts.isNotEmpty()) parts.joinToString(" • ") else null } private fun deviceSessionIcon(d: DeviceSessionInfo): ImageVector { val type = d.deviceType?.lowercase().orEmpty() - val os = d.osName?.lowercase().orEmpty() + val os = resolveDeviceOsName(d)?.lowercase().orEmpty() val hasBrowser = d.browserName?.isNotBlank() == true return when { "tablet" in type -> Icons.Filled.TabletAndroid @@ -692,7 +789,7 @@ private fun DeviceSessionDetailBottomSheet( ) DeviceSessionDetailParam( stringResource(Res.string.settings_devices_field_os), - d.osName + resolveDeviceOsName(d) ) DeviceSessionDetailParam( stringResource(Res.string.settings_devices_field_os_version), @@ -789,6 +886,8 @@ private fun DeviceSessionRow( ) { val headline = remember(d) { deviceHeadline(d, unknownDeviceLabel) } val detail = remember(d) { deviceDetailLine(d) } + val osLogo = remember(d) { deviceSessionLogoResource(d) } + val fallbackIcon = remember(d) { deviceSessionIcon(d) } val interaction = remember(d.sessionId) { MutableInteractionSource() } Row( modifier = Modifier @@ -808,12 +907,22 @@ private fun DeviceSessionRow( .background(MaterialTheme.colorScheme.surfaceContainerHigh), contentAlignment = Alignment.Center ) { - Icon( - imageVector = deviceSessionIcon(d), - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (osLogo != null) { + AsyncImage( + model = Res.getUri(osLogo), + contentDescription = null, + modifier = Modifier.size(28.dp), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface) + ) + } else { + Icon( + imageVector = fallbackIcon, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } Column( modifier = Modifier @@ -998,10 +1107,11 @@ fun SettingsDevicesScreen(onBack: () -> Unit) { containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { sessionList.forEachIndexed { index, d -> + val displaySession = deviceSessionForCurrentDevice(d) DeviceSessionRow( - d = d, + d = displaySession, unknownDeviceLabel = unknownDeviceLabel, - onOpen = { sheetDevice = d } + onOpen = { sheetDevice = displaySession } ) if (index < sessionList.lastIndex) { SettingsSurfaceCutDivider() diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.ios.kt new file mode 100644 index 0000000..1aafc0a --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.ios.kt @@ -0,0 +1,19 @@ +package ru.fromchat.platform + +import platform.UIKit.UIDevice + +private fun String?.trimIfNotBlank(): String? = + this?.trim()?.takeIf { it.isNotEmpty() } + +actual fun currentDeviceInfo(): CurrentDeviceInfo { + val current = UIDevice.currentDevice + + return CurrentDeviceInfo( + osName = current.systemName.trimIfNotBlank(), + osVersion = current.systemVersion.trimIfNotBlank(), + deviceType = "mobile", + deviceName = current.name.trimIfNotBlank(), + brand = "Apple", + model = current.model.trimIfNotBlank() + ) +}